diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte
index 9065cea28..c3526ac37 100644
--- a/src/components/Scene.svelte
+++ b/src/components/Scene.svelte
@@ -339,13 +339,15 @@
// kept rendering finger spheres). shouldSendHands forces it on a rep flip.
const lens = [left?.joints?.length ?? 0, right?.joints?.length ?? 0];
if (!shouldSendHands({ moved, hasJoints, prevLens: lastSentJointLens, lens })) return;
+ // 60 Q2: a hands<->controllers switch is state (reliable); a pose of the same rep is stream
+ const sameRep = !!lastSentJointLens && lastSentJointLens[0] === lens[0] && lastSentJointLens[1] === lens[1];
lastSentJointLens = lens;
lastHandsSendAt = now;
for (let i = 0; i < 2; i++) {
const pose = poses[i];
if (pose) lastHandPositions[i].fromArray(pose.pos);
}
- $peers.send({ type: 'vrhands', peerId: $peers.peer.id, left, right, active: true });
+ $peers.send({ type: 'vrhands', peerId: $peers.peer.id, left, right, active: true }, { fast: sameRep });
}
useTask((delta) => {
@@ -414,7 +416,9 @@
nowMs - lastCameraSendAt >= camGapMs) {
lastCameraSendAt = nowMs;
camContentEuler.setFromQuaternion(camContentQuat);
- $peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z], ...camExtras });
+ // 60 Q2: a pose of the stream rides the fast channel; a change of the extras (seat,
+ // feet, knocked…) is state and goes reliable, or a lost datagram could strand it
+ $peers.send({ type: 'camera', peerId: $peers.peer.id, position: camContentPos.toArray(), rotation: [camContentEuler.x, camContentEuler.y, camContentEuler.z], ...camExtras }, { fast: camExtrasKey === lastCameraExtras });
lastCameraExtras = camExtrasKey;
lastCameraPosition.copy(camContentPos);
lastCameraQuaternion.copy(camContentQuat);
@@ -1732,13 +1736,14 @@
dropToSurface($TControls.object, $objectsGroup);
}
$TControls.visible = true;
+ // 60 Q2: the gizmo drag is a stream (fast); its keyframe lands the last pose
$peers.send({
type: 'move',
uuid: $TControls.object.uuid,
pos: $TControls.object.position.toArray(),
rot: $TControls.object.rotation.toArray(),
scale: $TControls.object.scale.toArray()
- });
+ }, { fast: true });
}
}
diff --git a/src/components/menu/Settings.svelte b/src/components/menu/Settings.svelte
index 32df297f6..65d03db7e 100644
--- a/src/components/menu/Settings.svelte
+++ b/src/components/menu/Settings.svelte
@@ -48,6 +48,7 @@
import ShortcutsSettings from './settings/ShortcutsSettings.svelte';
import SceneSettings from './settings/SceneSettings.svelte'; // checkpoints, loading, water render inside
import ExplorerSettings from './settings/ExplorerSettings.svelte';
+ import StorageSettings from './settings/StorageSettings.svelte'; // 60 Q6: the local asset cache
import NodeTypesSection from './NodeTypesSection.svelte'; // 36 B7
// 36-export: the export defaults (its own file; also the Publish / Export modal's Settings tab)
import ExportSettingsSection from './ExportSettingsSection.svelte';
@@ -84,6 +85,8 @@
$: if ((settingsQuery || '').trim() || ($settingsOpen && $settingsSection === 'accessibility')) accessibilityExpanded = true;
let sceneExpanded = false;
let explorerExpanded = false;
+ let storageExpanded = false; // 60 Q6: Settings ▸ Storage (StorageSettings.svelte)
+ $: if ((settingsQuery || '').trim() || ($settingsOpen && $settingsSection === 'storage')) storageExpanded = true;
let connectionExpanded = false;
let aboutExpanded = false;
let vrExpanded = false; // D7: edit-cap toasts deep-link here ('vr')
@@ -562,6 +565,10 @@
{#snippet header()}Explorer{/snippet}
+
+ {#snippet header()}Storage{/snippet}
+
+
{#snippet header()}VR{/snippet}
diff --git a/src/components/menu/settings/StorageSettings.svelte b/src/components/menu/settings/StorageSettings.svelte
new file mode 100644
index 000000000..ebf30e7aa
--- /dev/null
+++ b/src/components/menu/settings/StorageSettings.svelte
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+ {#snippet desc()}
+
+ {#if $assetCacheBackend === 'none'}
+ This browser gives the page no storage to keep files in, so everything is downloaded
+ each time.
+ {:else}
+ {fmtBytes(usage.bytes)} in {usage.blobs}
+ {usage.blobs === 1 ? 'file' : 'files'}{parts ? ' — ' + parts : ''}. Packs, skies,
+ converted textures and downloads are kept so they load again without the network. The
+ app itself is never kept here, so updates always arrive.
+ {/if}
+ {#if cleared} {cleared}{/if}
+
+ {/snippet}
+
+
+
+ assetCacheCapMB.set(Number(v))}
+ />
+
+
+ {#snippet desc()}
+
+ {#if $persistState === 'granted'}
+ The browser keeps this storage even when it is short of space.
+ {:else if $persistState === 'unsupported'}
+ This browser decides by itself when to clear site storage.
+ {:else}
+ The browser may clear the cache when it is short of space. Asking lets it keep it; some
+ browsers decide by how often you visit.
+ {/if}
+
+ {/snippet}
+ {#if $persistState === 'not-granted'}
+
+ {/if}
+
+
+
+
+
+
+
+
diff --git a/src/lib/animatedImports.js b/src/lib/animatedImports.js
index d3cb6e020..87e77a6b6 100644
--- a/src/lib/animatedImports.js
+++ b/src/lib/animatedImports.js
@@ -481,10 +481,12 @@ async function fetchAnimRef(ref) {
const url = packRefUrl(ref);
let job = refFiles.get(url);
if (!job) {
- job = fetch(url).then((res) => {
- if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + url);
- return res.arrayBuffer();
- });
+ // 60 Q6: a pack file like any other — through the pack cache (the local asset cache)
+ job = import('./packCache').then((m) =>
+ m.fetchPackBuffer(url).catch((/** @type {any} */ e) => {
+ throw new Error(String(e?.message ?? e) + ' ' + url);
+ })
+ );
refFiles.set(url, job);
job.catch(() => refFiles.delete(url));
}
diff --git a/src/lib/assetCache.js b/src/lib/assetCache.js
new file mode 100644
index 000000000..783c99266
--- /dev/null
+++ b/src/lib/assetCache.js
@@ -0,0 +1,454 @@
+import { writable, get } from 'svelte/store';
+import { safeStorage } from './safeStorage';
+import {
+ emptyIndex,
+ normalizeIndex,
+ usageOf,
+ planEviction,
+ orphanedBy,
+ urlKey,
+ CAP_CHOICES_MB,
+ DEFAULT_CAP_MB
+} from './assetCacheCore.js';
+
+// 60 Q6 — THE LOCAL ASSET CACHE: packs, skies, converted textures and downloaded imports, kept
+// on this device in the Origin Private File System, keyed by CONTENT HASH (assetCacheCore.js
+// has the model: keys -> hash, one blob per hash, LRU under a cap).
+//
+// WHY OPFS. 39 P5 kept pack files in CacheStorage, which can be listed and sized but stores a
+// whole Response per url — the same bytes at two urls twice — and gives a page no way to read a
+// file without materialising the Response. OPFS is a real per-origin filesystem: a blob per hash,
+// one small index file, a byte count that is the file's own. Where OPFS cannot write (no
+// `createWritable`: old Safari, a non-secure origin) the SAME model runs on CacheStorage, and
+// where neither exists every call degrades to a plain network fetch, so no caller ever asks.
+//
+// WHAT IT NEVER HOLDS: the app shell. index.html, the JS/CSS chunks and the service worker stay
+// under the no-cache SW rule — an update must always reach the next load. Only CONTENT reaches
+// this module: pack files from the packs CDN, sky images, CC0 downloads, conversion outputs.
+//
+// PERSISTENCE: browsers may evict an origin's storage under pressure unless the page asked
+// `navigator.storage.persist()`. It is asked ONCE, from a toast that says why, the first time
+// something lands here (`assetCache:persistAsked`), never on boot and never again.
+//
+// A LEAF for the app (svelte/store + safeStorage + the pure core); the toast is a dynamic import.
+
+const DIR = 'tp-asset-cache';
+const CACHE_NAME = 'tp-asset-cache-v1';
+const CACHE_BASE = '/__tp-asset-cache/';
+const INDEX_FLUSH_MS = 400;
+const CAP_KEY = 'assetCache:capMB';
+const ASKED_KEY = 'assetCache:persistAsked';
+
+/** @typedef {{name: string, read(hash: string): Promise, write(hash: string, buf: ArrayBuffer): Promise, remove(hash: string): Promise, list(): Promise, readIndex(): Promise, writeIndex(text: string): Promise, wipe(): Promise}} Backend */
+
+/** @returns {Promise} */
+async function opfsBackend() {
+ const storage = /** @type {any} */ (typeof navigator !== 'undefined' ? navigator.storage : null);
+ if (!storage || typeof storage.getDirectory !== 'function') return null;
+ const root = await storage.getDirectory();
+ const dir = await root.getDirectoryHandle(DIR, { create: true });
+ const blobs = await dir.getDirectoryHandle('blobs', { create: true });
+ const probe = await dir.getFileHandle('.probe', { create: true });
+ if (typeof probe.createWritable !== 'function') return null;
+ /** @param {any} handle @param {ArrayBuffer | string} data */
+ const writeFile = async (handle, data) => {
+ const w = await handle.createWritable();
+ await w.write(data);
+ await w.close();
+ };
+ return {
+ name: 'opfs',
+ async read(hash) {
+ try {
+ const fh = await blobs.getFileHandle(hash);
+ return await (await fh.getFile()).arrayBuffer();
+ } catch {
+ return null;
+ }
+ },
+ async write(hash, buf) {
+ await writeFile(await blobs.getFileHandle(hash, { create: true }), buf);
+ },
+ async remove(hash) {
+ try {
+ await blobs.removeEntry(hash);
+ } catch {}
+ },
+ async list() {
+ /** @type {string[]} */
+ const out = [];
+ for await (const name of blobs.keys()) out.push(name);
+ return out;
+ },
+ async readIndex() {
+ try {
+ const fh = await dir.getFileHandle('index.json');
+ return JSON.parse(await (await fh.getFile()).text());
+ } catch {
+ return null;
+ }
+ },
+ async writeIndex(text) {
+ await writeFile(await dir.getFileHandle('index.json', { create: true }), text);
+ },
+ async wipe() {
+ for await (const name of blobs.keys()) {
+ try {
+ await blobs.removeEntry(name);
+ } catch {}
+ }
+ }
+ };
+}
+
+/** the same model on CacheStorage (one Response per blob, one for the index) @returns {Promise} */
+async function cacheBackend() {
+ if (typeof caches === 'undefined') return null;
+ const cache = await caches.open(CACHE_NAME);
+ const blobUrl = (/** @type {string} */ hash) => CACHE_BASE + 'blob/' + hash;
+ return {
+ name: 'cachestorage',
+ async read(hash) {
+ const res = await cache.match(blobUrl(hash));
+ return res ? res.arrayBuffer() : null;
+ },
+ async write(hash, buf) {
+ await cache.put(blobUrl(hash), new Response(buf.slice(0)));
+ },
+ async remove(hash) {
+ await cache.delete(blobUrl(hash));
+ },
+ async list() {
+ return (await cache.keys())
+ .map((r) => new URL(r.url).pathname)
+ .filter((p) => p.startsWith(CACHE_BASE + 'blob/'))
+ .map((p) => p.slice((CACHE_BASE + 'blob/').length));
+ },
+ async readIndex() {
+ const res = await cache.match(CACHE_BASE + 'index.json');
+ return res ? res.json() : null;
+ },
+ async writeIndex(text) {
+ await cache.put(
+ CACHE_BASE + 'index.json',
+ new Response(text, { headers: { 'content-type': 'application/json' } })
+ );
+ },
+ async wipe() {
+ for (const req of await cache.keys()) await cache.delete(req);
+ }
+ };
+}
+
+/** @type {Promise | null} */
+let backendJob = null;
+/** @type {Backend | null} */
+let backend = null;
+let index = emptyIndex();
+/** the index, republished on every change (Settings, Storage, packCache read it) */
+export const assetCacheIndex = writable(index);
+/** which store is in use: 'opfs' | 'cachestorage' | 'none' | '' (not known yet) */
+export const assetCacheBackend = writable('');
+
+/** @returns {number} */
+function readCapMB() {
+ const n = Number(safeStorage.getItem(CAP_KEY));
+ return CAP_CHOICES_MB.includes(n) ? n : DEFAULT_CAP_MB;
+}
+/** the LRU cap in MB (Settings ▸ Storage); LOCAL, persisted */
+export const assetCacheCapMB = writable(readCapMB());
+let capRead = false;
+assetCacheCapMB.subscribe((mb) => {
+ if (!capRead) return void (capRead = true); // the value we just read is not a choice
+ if (CAP_CHOICES_MB.includes(mb)) safeStorage.setItem(CAP_KEY, String(mb));
+ if (backend) void enforceCap();
+});
+
+const stats = { hits: 0, misses: 0, writes: 0, evicted: 0, fetches: 0 };
+
+/** Open the store and read the index, once. Resolves whether a store is usable. @returns {Promise} */
+export function assetCacheReady() {
+ if (!backendJob) {
+ backendJob = (async () => {
+ /** @type {Backend | null} */
+ let b = null;
+ try {
+ b = await opfsBackend();
+ } catch {
+ b = null;
+ }
+ if (!b) {
+ try {
+ b = await cacheBackend();
+ } catch {
+ b = null;
+ }
+ }
+ if (b) {
+ try {
+ index = normalizeIndex(await b.readIndex());
+ } catch {
+ index = emptyIndex();
+ }
+ // a tab that died between a blob write and its index flush leaves a blob no key
+ // names, and one that died mid-eviction a key whose blob is gone: reconcile both
+ try {
+ const held = new Set(await b.list());
+ let dropped = false;
+ for (const [k, e] of Object.entries(index.entries)) {
+ if (!held.has(e.hash)) {
+ delete index.entries[k];
+ dropped = true;
+ }
+ }
+ const named = new Set(Object.values(index.entries).map((e) => e.hash));
+ for (const h of held) if (!named.has(h)) await b.remove(h);
+ if (dropped) scheduleFlush();
+ } catch {}
+ }
+ backend = b;
+ assetCacheBackend.set(b ? b.name : 'none');
+ publish();
+ return b;
+ })();
+ }
+ return backendJob.then((b) => !!b);
+}
+
+function publish() {
+ assetCacheIndex.set(index);
+}
+
+/** @type {ReturnType | null} */
+let flushTimer = null;
+function scheduleFlush() {
+ if (flushTimer) return;
+ flushTimer = setTimeout(() => {
+ flushTimer = null;
+ void flushIndex();
+ }, INDEX_FLUSH_MS);
+}
+/** Write the index now (also on pagehide). */
+export async function flushIndex() {
+ if (!backend) return;
+ try {
+ await backend.writeIndex(JSON.stringify(index));
+ } catch {}
+}
+if (typeof addEventListener === 'function') addEventListener('pagehide', () => void flushIndex());
+
+/** SHA-256 hex of the bytes, or null where SubtleCrypto is missing @param {ArrayBuffer} buf */
+async function sha256(buf) {
+ try {
+ const digest = await crypto.subtle.digest('SHA-256', buf);
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
+ } catch {
+ return null;
+ }
+}
+
+/** Is `key` held (by the index; the bytes may still turn out gone)? @param {string} key */
+export function hasCached(key) {
+ return !!index.entries[key];
+}
+
+/** The index row of `key`, or null @param {string} key */
+export function cachedEntry(key) {
+ return index.entries[key] ?? null;
+}
+
+/**
+ * The cached bytes of `key`, or null. A hit stamps the key as just used (LRU).
+ * @param {string | null} key @returns {Promise}
+ */
+export async function readCached(key) {
+ if (!key || !(await assetCacheReady()) || !backend) return null;
+ const e = index.entries[key];
+ if (!e) {
+ stats.misses++;
+ return null;
+ }
+ const buf = await backend.read(e.hash);
+ if (!buf) {
+ // the blob went (another tab cleared, the browser evicted): forget the row
+ delete index.entries[key];
+ publish();
+ scheduleFlush();
+ stats.misses++;
+ return null;
+ }
+ e.lastUsed = Date.now();
+ scheduleFlush();
+ stats.hits++;
+ return buf;
+}
+
+/**
+ * Keep `buffer` under `key`. Best effort: a full quota or a refused store leaves the app as it
+ * was (the bytes are already in memory). Resolves the content hash, or null when nothing was kept.
+ * @param {string | null} key @param {ArrayBuffer} buffer
+ * @param {{kind: string, type?: string, meta?: any}} info
+ * @returns {Promise}
+ */
+export async function writeCached(key, buffer, info) {
+ if (!key || !buffer || !(await assetCacheReady()) || !backend) return null;
+ const hash = await sha256(buffer);
+ if (!hash) return null;
+ const shared = Object.values(index.entries).some((e) => e.hash === hash);
+ try {
+ if (!shared) await backend.write(hash, buffer);
+ } catch {
+ return null;
+ }
+ const now = Date.now();
+ index.entries[key] = {
+ hash,
+ bytes: buffer.byteLength,
+ kind: info.kind,
+ ...(info.type ? { type: info.type } : {}),
+ lastUsed: now,
+ at: now,
+ ...(info.meta !== undefined ? { meta: info.meta } : {})
+ };
+ stats.writes++;
+ await enforceCap([key]);
+ publish();
+ scheduleFlush();
+ maybeOfferPersist();
+ return hash;
+}
+
+/** Drop the least recently used keys until the cache fits its cap. @param {string[]} [protect] */
+async function enforceCap(protect = []) {
+ if (!backend) return;
+ const plan = planEviction(index, get(assetCacheCapMB) * 1024 * 1024, protect);
+ if (!plan.keys.length) return;
+ for (const k of plan.keys) delete index.entries[k];
+ for (const h of plan.hashes) await backend.remove(h);
+ stats.evicted += plan.keys.length;
+ publish();
+ scheduleFlush();
+}
+
+/**
+ * The bytes of a network file through the cache: the held copy when there is one (no network
+ * at all), else a fetch whose bytes are kept. Throws like fetch does on a failed request.
+ * @param {string} url @param {{kind: string, version?: string, init?: RequestInit}} opts
+ * @returns {Promise}
+ */
+export async function fetchCached(url, opts) {
+ const key = urlKey(url, opts.version);
+ const held = await readCached(key);
+ if (held) return held;
+ stats.fetches++;
+ const res = await fetch(url, opts.init);
+ if (!res.ok) throw Object.assign(new Error('HTTP ' + res.status), { status: res.status });
+ const buffer = await res.arrayBuffer();
+ void writeCached(key, buffer, {
+ kind: opts.kind,
+ type: res.headers.get('content-type') || undefined
+ });
+ return buffer;
+}
+
+/**
+ * Forget `keys`; blobs no other key names are deleted. Resolves the bytes freed.
+ * @param {string[]} keys @returns {Promise}
+ */
+export async function deleteCached(keys) {
+ if (!(await assetCacheReady()) || !backend) return 0;
+ const orphans = orphanedBy(index, keys);
+ let freed = 0;
+ for (const h of orphans) {
+ const e = Object.values(index.entries).find((x) => x.hash === h);
+ freed += e?.bytes ?? 0;
+ await backend.remove(h);
+ }
+ for (const k of keys) delete index.entries[k];
+ publish();
+ scheduleFlush();
+ return freed;
+}
+
+/** Settings ▸ Storage ▸ Clear: everything, every kind. Resolves the bytes freed. */
+export async function clearAssetCache() {
+ if (!(await assetCacheReady()) || !backend) return 0;
+ const freed = usageOf(index).bytes;
+ try {
+ await backend.wipe();
+ } catch {}
+ index = emptyIndex();
+ publish();
+ await flushIndex();
+ return freed;
+}
+
+// ---- persistence: asked once, with the reason ------------------------------------------------
+
+/** 'granted' | 'not-granted' | 'unsupported' | '' (not read yet) */
+export const persistState = writable('');
+
+export async function refreshPersistState() {
+ const storage = /** @type {any} */ (typeof navigator !== 'undefined' ? navigator.storage : null);
+ if (!storage || typeof storage.persisted !== 'function') {
+ persistState.set('unsupported');
+ return;
+ }
+ try {
+ persistState.set((await storage.persisted()) ? 'granted' : 'not-granted');
+ } catch {
+ persistState.set('unsupported');
+ }
+}
+
+/** Was the question put already (on this device)? */
+export function persistAsked() {
+ return safeStorage.getItem(ASKED_KEY) === '1';
+}
+
+/** Ask the browser to keep this origin's storage. The ONE call site of `persist()`. */
+export async function requestPersist() {
+ safeStorage.setItem(ASKED_KEY, '1');
+ const storage = /** @type {any} */ (typeof navigator !== 'undefined' ? navigator.storage : null);
+ if (!storage || typeof storage.persist !== 'function') {
+ persistState.set('unsupported');
+ return false;
+ }
+ let ok = false;
+ try {
+ ok = await storage.persist();
+ } catch {}
+ persistState.set(ok ? 'granted' : 'not-granted');
+ return ok;
+}
+
+let offered = false;
+/** The first time something lands in the cache, say why we want to keep it — once, ever. */
+function maybeOfferPersist() {
+ if (offered || persistAsked()) return;
+ offered = true;
+ void (async () => {
+ await refreshPersistState();
+ if (get(persistState) !== 'not-granted') return; // granted already, or no such API
+ safeStorage.setItem(ASKED_KEY, '1');
+ const { showToastById } = await import('../stores/appStore.js');
+ showToastById(
+ 'asset-cache-persist',
+ 'Downloaded packs and skies are kept on this device so scenes load faster and work offline. Keep them even when the browser is short of space?',
+ [
+ { label: 'Keep them', action: () => void requestPersist() },
+ { label: 'Not now', action: () => {} }
+ ]
+ );
+ })();
+}
+
+/** for the suites (`__stores.assetCache`) */
+export function assetCacheStats() {
+ return {
+ ...stats,
+ backend: backend?.name ?? 'none',
+ ...usageOf(index),
+ capMB: get(assetCacheCapMB)
+ };
+}
diff --git a/src/lib/assetCacheCore.js b/src/lib/assetCacheCore.js
new file mode 100644
index 000000000..23a2ccc4b
--- /dev/null
+++ b/src/lib/assetCacheCore.js
@@ -0,0 +1,190 @@
+// 60 Q6 — THE LOCAL ASSET CACHE, its pure half: keys, the index shape and the LRU plan.
+//
+// A ZERO-IMPORT LEAF (the wireBatch / alignCore shape), so every rule that decides WHAT is kept
+// and WHAT goes is unit-testable with no OPFS and no browser. `assetCache.js` is the runtime
+// that reads and writes bytes; this file only does arithmetic over the index.
+//
+// THE MODEL. Bytes are stored ONCE per content hash (a "blob"); the index maps a KEY to the hash
+// it resolved to. Two keys may share a blob — the same file reached at two urls, or a converted
+// texture identical to one already held — and a blob is freed only when no key refers to it.
+// * url:[#v=] — a network file (pack files, skies, CC0 downloads). The
+// hash is learned when the bytes arrive; the key is what a later load asks for. A version tag
+// is part of the key where the url alone does not pin the bytes (a same-origin sky ships with
+// the build, so it is keyed by the build too).
+// * derived::: — the OUTPUT of a conversion (an optimized import's
+// KTX2 textures), keyed by the INPUT's content hash and the exact settings. The same file
+// imported again with the same settings never converts twice.
+//
+// LRU: every read stamps `lastUsed`; when the blobs' total passes the cap, keys go oldest first
+// (never the protected ones — the key just written) until the blobs still referenced fit.
+
+/** @typedef {{hash: string, bytes: number, kind: string, type?: string, lastUsed: number, at: number, meta?: any}} CacheEntry */
+/** @typedef {{v: 1, entries: Record}} CacheIndex */
+
+export const INDEX_VERSION = 1;
+export const CACHE_KINDS = ['pack', 'sky', 'texture', 'import'];
+/** the cap choices Settings offers, in MB (0 is not offered: Clear is the off switch) */
+export const CAP_CHOICES_MB = [256, 512, 1024, 2048];
+export const DEFAULT_CAP_MB = 1024;
+
+/**
+ * JSON with SORTED keys at every depth, so two equal settings objects give one key whatever
+ * order their fields were written in.
+ * @param {any} value @returns {string}
+ */
+export function stableStringify(value) {
+ if (value === undefined) return 'null';
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
+ if (Array.isArray(value)) return '[' + value.map((v) => stableStringify(v)).join(',') + ']';
+ const keys = Object.keys(value)
+ .filter((k) => value[k] !== undefined)
+ .sort();
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
+}
+
+/**
+ * The key of a network file. Only absolute http(s) urls are cacheable — a blob:/data: url is
+ * already in memory, and a relative one must be resolved by the caller first.
+ * @param {string} url @param {string} [version] @returns {string | null}
+ */
+export function urlKey(url, version) {
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) return null;
+ const bare = url.replace(/#.*$/, '');
+ return 'url:' + bare + (version ? '#v=' + version : '');
+}
+
+/** the url a `url:` key was made from (without its version tag), else null @param {string} key */
+export function urlOfKey(key) {
+ if (typeof key !== 'string' || !key.startsWith('url:')) return null;
+ return key.slice(4).replace(/#v=.*$/, '');
+}
+
+/**
+ * The key of a conversion's output.
+ * @param {string} kind @param {string} sourceHash @param {any} params @returns {string | null}
+ */
+export function derivedKey(kind, sourceHash, params) {
+ if (!kind || typeof sourceHash !== 'string' || !/^[0-9a-f]{16,}$/i.test(sourceHash)) return null;
+ return 'derived:' + kind + ':' + sourceHash.toLowerCase() + ':' + stableStringify(params ?? null);
+}
+
+/** @returns {CacheIndex} */
+export function emptyIndex() {
+ return { v: INDEX_VERSION, entries: {} };
+}
+
+/**
+ * Bring a stored index back to shape: an unknown version or a malformed row is dropped (the
+ * cache is a cache — losing a row costs one download), never trusted.
+ * @param {any} raw @returns {CacheIndex}
+ */
+export function normalizeIndex(raw) {
+ const out = emptyIndex();
+ if (
+ !raw ||
+ typeof raw !== 'object' ||
+ raw.v !== INDEX_VERSION ||
+ !raw.entries ||
+ typeof raw.entries !== 'object'
+ )
+ return out;
+ for (const [key, e] of Object.entries(raw.entries)) {
+ if (!e || typeof e !== 'object') continue;
+ if (typeof e.hash !== 'string' || !/^[0-9a-f]{16,}$/.test(e.hash)) continue;
+ if (!Number.isFinite(e.bytes) || e.bytes < 0) continue;
+ out.entries[key] = {
+ hash: e.hash,
+ bytes: e.bytes,
+ kind: CACHE_KINDS.includes(e.kind) ? e.kind : 'import',
+ ...(typeof e.type === 'string' ? { type: e.type } : {}),
+ lastUsed: Number.isFinite(e.lastUsed) ? e.lastUsed : 0,
+ at: Number.isFinite(e.at) ? e.at : 0,
+ ...(e.meta !== undefined ? { meta: e.meta } : {})
+ };
+ }
+ return out;
+}
+
+/**
+ * The blobs the index refers to: hash -> {bytes, refs}. A blob's size is counted ONCE however
+ * many keys share it.
+ * @param {CacheIndex} index @returns {Map}
+ */
+export function blobsOf(index) {
+ /** @type {Map} */
+ const blobs = new Map();
+ for (const e of Object.values(index.entries)) {
+ const b = blobs.get(e.hash);
+ if (b) b.refs++;
+ else blobs.set(e.hash, { bytes: e.bytes, refs: 1 });
+ }
+ return blobs;
+}
+
+/**
+ * What the cache holds: total bytes (blobs counted once), keys, blobs, and bytes per kind (a
+ * shared blob counts toward the kind of the first key that names it, so the kinds sum to the
+ * total).
+ * @param {CacheIndex} index
+ */
+export function usageOf(index) {
+ /** @type {Record} */
+ const byKind = Object.fromEntries(CACHE_KINDS.map((k) => [k, 0]));
+ const seen = new Set();
+ let bytes = 0;
+ for (const e of Object.values(index.entries)) {
+ if (seen.has(e.hash)) continue;
+ seen.add(e.hash);
+ bytes += e.bytes;
+ byKind[e.kind] = (byKind[e.kind] ?? 0) + e.bytes;
+ }
+ return { bytes, keys: Object.keys(index.entries).length, blobs: seen.size, byKind };
+}
+
+/**
+ * THE LRU PLAN: which keys to drop so the referenced blobs fit `capBytes`, oldest `lastUsed`
+ * first (ties by key, so the plan is deterministic), never a `protect`ed key; and which blobs
+ * that frees (a blob goes only when its LAST key goes). Pure — the caller applies it.
+ * @param {CacheIndex} index @param {number} capBytes @param {Iterable} [protect]
+ * @returns {{keys: string[], hashes: string[], bytesAfter: number}}
+ */
+export function planEviction(index, capBytes, protect = []) {
+ const keep = new Set(protect);
+ const blobs = blobsOf(index);
+ let total = 0;
+ for (const b of blobs.values()) total += b.bytes;
+ /** @type {string[]} */
+ const keys = [];
+ /** @type {string[]} */
+ const hashes = [];
+ if (total <= capBytes) return { keys, hashes, bytesAfter: total };
+ const order = Object.entries(index.entries)
+ .filter(([k]) => !keep.has(k))
+ .sort((a, b) => a[1].lastUsed - b[1].lastUsed || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
+ for (const [key, e] of order) {
+ if (total <= capBytes) break;
+ keys.push(key);
+ const b = /** @type {{bytes: number, refs: number}} */ (blobs.get(e.hash));
+ b.refs--;
+ if (b.refs === 0) {
+ hashes.push(e.hash);
+ total -= b.bytes;
+ }
+ }
+ return { keys, hashes, bytesAfter: total };
+}
+
+/**
+ * Which blobs no key refers to any more once `keys` are removed (the delete/clear-one path).
+ * @param {CacheIndex} index @param {Iterable} keys @returns {string[]}
+ */
+export function orphanedBy(index, keys) {
+ const gone = new Set(keys);
+ const still = new Set();
+ const touched = new Set();
+ for (const [k, e] of Object.entries(index.entries)) {
+ if (gone.has(k)) touched.add(e.hash);
+ else still.add(e.hash);
+ }
+ return [...touched].filter((h) => !still.has(h));
+}
diff --git a/src/lib/cc0/cc0Fetch.js b/src/lib/cc0/cc0Fetch.js
index 51cd8080c..702f351eb 100644
--- a/src/lib/cc0/cc0Fetch.js
+++ b/src/lib/cc0/cc0Fetch.js
@@ -5,11 +5,13 @@
// one IndexedDB store under `cc0:` with the time it arrived. A fresh entry answers with no
// network at all; an expired one is refetched, and when that refetch FAILS (offline, the site
// down, the relay down) the old answer is served with `stale: true`, so the browser keeps working
-// on what it saw last and says so. Downloads are NOT cached here — they become Library items,
-// which are content-hashed and deduped by the import entry already.
+// on what it saw last and says so. Downloads become Library items (content-hashed and deduped by
+// the import entry); 60 Q6 also keeps their BYTES in the local asset cache (`fetchBytes`).
import { idbGet, idbPut } from '../idb';
import { safeStorage } from '../safeStorage';
import { PH_API, ACG_PAGE, DEFAULT_CC0_PROXY, classifyError } from './cc0Core.js';
+import { readCached, writeCached } from '../assetCache';
+import { urlKey } from '../assetCacheCore.js';
const HOUR = 3600_000;
/** how long an answer is fresh, per endpoint family */
@@ -110,6 +112,21 @@ export async function acgResolve(file) {
* @returns {Promise}
*/
export async function fetchBytes(url, onProgress, signal) {
+ // 60 Q6: a download is kept in the local asset cache (kind `import`), so importing the same
+ // file again — after deleting it from the library, or into another project — costs no network
+ const key = urlKey(url);
+ const held = await readCached(key);
+ if (held) {
+ onProgress?.(held.byteLength, held.byteLength);
+ return new Uint8Array(held);
+ }
+ const bytes = await downloadBytes(url, onProgress, signal);
+ void writeCached(key, /** @type {ArrayBuffer} */ (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)), { kind: 'import' });
+ return bytes;
+}
+
+/** @param {string} url @param {(done: number, total: number) => void} [onProgress] @param {AbortSignal} [signal] @returns {Promise} */
+async function downloadBytes(url, onProgress, signal) {
let res;
try {
res = await fetch(url, { credentials: 'omit', signal });
diff --git a/src/lib/cloudHooks.js b/src/lib/cloudHooks.js
index 5b09cd835..fa3cb43b8 100644
--- a/src/lib/cloudHooks.js
+++ b/src/lib/cloudHooks.js
@@ -31,6 +31,9 @@ const ALWAYS_ALLOWED = new Set([
'getnodedefs',
'getjoints',
'getchat', // 37 R15: the chat-history REQUEST (its reply stays gateable, the getnodes rule)
+ // 60 Q2: "I opened the fast movement channel on this connection" — transport, no content. A
+ // gated hello would leave a viewer's avatar on the slow channel while the host thinks it is fast
+ 'fastchan',
// A1/A2: WHERE A PEER IS STANDING. On the floor because it is the ROOM GATE'S OWN
// EVIDENCE - a plugin that gated `atscene` would silently switch off scene adoption
// AND every room gate built on it (`canApplyByRoom` reads no evidence, so it allows
diff --git a/src/lib/debugHooks.js b/src/lib/debugHooks.js
index 46aa9ba1b..0ccf384f9 100644
--- a/src/lib/debugHooks.js
+++ b/src/lib/debugHooks.js
@@ -43,6 +43,8 @@ export const DEBUG_HOOKS = [
['placementPrefs', () => import('./placementPrefs.js')],
['placementDims', () => import('./placementDims.js')],
['packCache', () => import('./packCache.js')],
+ // 60 Q6: the local asset cache (OPFS) — index, stats, clear, persist
+ ['assetCache', () => import('./assetCache.js')],
['placeGhost', () => import('./placeGhost.js')],
['tourVR', () => import('./tours/vrPanel.js')],
// 36-share (B13): the recorder + its stores
diff --git a/src/lib/fastChannel.js b/src/lib/fastChannel.js
new file mode 100644
index 000000000..3b84c9020
--- /dev/null
+++ b/src/lib/fastChannel.js
@@ -0,0 +1,292 @@
+// 60 Q2 — THE FAST MOVEMENT CHANNEL.
+//
+// Every message used to ride ONE PeerJS DataConnection per peer, which is ordered and reliable.
+// That is what an edit needs and the opposite of what a pose stream needs: on a lossy link one
+// lost packet holds back EVERYTHING behind it until SCTP retransmits it (head-of-line blocking),
+// so an avatar that is sent 20 times a second freezes for a retransmit timeout and then jumps
+// through a burst of poses that were all stale by the time they arrived.
+//
+// So each pair of peers opens a SECOND channel on the RTCPeerConnection they already share,
+// `{ordered: false, maxRetransmits: 0}`: a lost pose is simply gone, and the next one (50 ms
+// later) replaces it. Only latest-wins pose messages go there — avatars (`camera`), VR hands
+// (`vrhands`) and the STREAMED `move`s of a drag, a held object or a physics body. Everything
+// else — every edit, every committed move, every request — stays on the reliable channel.
+//
+// FOUR RULES:
+// * A NEGOTIATED channel (`negotiated: true, id: FAST_CHANNEL_ID`) on the PeerJS connection's
+// own RTCPeerConnection: no extra signaling, no second ICE run, no renegotiation (an SCTP
+// association that exists takes more streams without one). Both sides must create it, so it
+// is created only after the OTHER side said it can (`fc: 1` in the `modules` handshake, then
+// a `fastchan` hello down the same conn so both ends pick the same RTCPeerConnection).
+// * GRACEFUL FALLBACK: a peer that never said `fc: 1` (an older build), a channel that is not
+// open yet, a message too big for one unreliable datagram — all go down the reliable conn
+// exactly as before. The wire to an older peer is byte-identical except one additive field.
+// * SEQUENCE NUMBERS: every pose message carries `ps`, one monotonic counter per sender. A
+// receiver keeps the newest `ps` per (sender, key) and drops anything older — so an
+// unordered datagram never moves an avatar backwards, and a late streamed `move` never
+// overwrites the committed (reliable, newer) one that ended the drag.
+// * A TRAILING KEYFRAME: the LAST pose of a stream may be the one that is lost, and nothing
+// would ever correct it (a camera that stopped moving stops sending). So when a key goes
+// quiet after a fast send, its last payload is re-sent once on the reliable channel with
+// the SAME `ps` — dropped as a duplicate where the datagram arrived, applied where it didn't.
+//
+// Kill switch: `safeStorage.fastChannel = 'off'` (the e2e's "before" and "older build").
+import { safeStorage } from './safeStorage';
+
+/** the SCTP stream id both ends give the negotiated channel (PeerJS's own channel is 0 or 1) */
+export const FAST_CHANNEL_ID = 7;
+export const FAST_CHANNEL_LABEL = 'tp-fast';
+/** above this a message goes reliable: one unreliable message should fit a few datagrams, and a
+ * lost fragment loses the whole message */
+export const FAST_MAX_BYTES = 12000;
+/** past this many queued bytes a pose is SKIPPED, not queued: behind a backlog it would only arrive
+ * stale, and the next pose (or the trailing keyframe) replaces it anyway */
+export const FAST_MAX_BUFFERED = 256 * 1024;
+/** how long a key must be quiet before its trailing keyframe goes out */
+export const KEYFRAME_MS = 250;
+
+/** The pose types and how each is keyed: a camera/hands stream per sender, a move per object. */
+export const POSE_TYPES = new Set(['camera', 'vrhands', 'move']);
+
+/** @param {any} payload @returns {string | null} */
+export function poseKey(payload) {
+ const type = payload?.type;
+ if (type === 'camera' || type === 'vrhands') return type;
+ if (type === 'move' && typeof payload.uuid === 'string') return 'move:' + payload.uuid;
+ return null;
+}
+
+/** Is the channel allowed on this device? @returns {boolean} */
+export function fastChannelEnabled() {
+ try {
+ return safeStorage.getItem('fastChannel') !== 'off';
+ } catch {
+ return true;
+ }
+}
+
+// Seeded from the clock (x100) so a RELOADED sender (same peer id, fresh counter) still starts
+// above everything its previous page sent — up to 100k pose messages a second before it could
+// outrun the clock. Receivers also forget a peer on disconnect.
+let seq = Date.now() * 100;
+/** @returns {number} */
+export function nextPoseSeq() {
+ return ++seq;
+}
+
+/**
+ * Give a pose payload its `ps` (once — a keyframe re-send keeps the original). Non-pose
+ * payloads come back untouched. Returns the SAME object, stamped.
+ * @param {any} payload @returns {any}
+ */
+export function stampPose(payload) {
+ if (
+ payload &&
+ typeof payload === 'object' &&
+ POSE_TYPES.has(payload.type) &&
+ !Number.isFinite(payload.ps)
+ )
+ payload.ps = nextPoseSeq();
+ return payload;
+}
+
+/** @type {((peerId: string, payload: any, via: 'fast' | 'reliable') => void) | null} */
+let poseTap = null;
+/**
+ * A MEASUREMENT seam (the loss e2e): `fn` sees every pose that passed the stale filter, i.e.
+ * every pose the app applies, with the channel it came on. Null (the default) costs one check.
+ * @param {((peerId: string, payload: any, via: 'fast' | 'reliable') => void) | null} fn
+ */
+export function setPoseTap(fn) {
+ poseTap = typeof fn === 'function' ? fn : null;
+}
+/** @param {string} peerId @param {any} payload @param {'fast' | 'reliable'} via */
+export function tapPose(peerId, payload, via) {
+ if (poseTap && poseKey(payload) !== null) poseTap(peerId, payload, via);
+}
+
+/**
+ * The receive-side stale filter. `accept` answers false for a pose older than (or equal to)
+ * the newest already applied from that sender for that key. A payload with no `ps` (an older
+ * build) is always accepted — and records nothing.
+ */
+export function createStaleFilter() {
+ /** `${peer}|${key}` -> newest ps @type {Map} */
+ const newest = new Map();
+ const stats = { accepted: 0, stale: 0 };
+ return {
+ stats,
+ /** @param {string} peerId @param {any} payload @returns {boolean} */
+ accept(peerId, payload) {
+ const ps = payload?.ps;
+ if (!Number.isFinite(ps)) return true;
+ const key = poseKey(payload);
+ if (!key) return true;
+ const id = peerId + '|' + key;
+ const last = newest.get(id);
+ if (last !== undefined && ps <= last) {
+ stats.stale++;
+ return false;
+ }
+ newest.set(id, ps);
+ stats.accepted++;
+ return true;
+ },
+ /** @param {string} peerId */
+ dropPeer(peerId) {
+ const prefix = peerId + '|';
+ for (const id of [...newest.keys()]) if (id.startsWith(prefix)) newest.delete(id);
+ },
+ clear() {
+ newest.clear();
+ },
+ size: () => newest.size
+ };
+}
+
+/**
+ * The trailing keyframes. `noteFast` (re)arms a key's timer with its newest payload and the
+ * peers that got it unreliably; `noteReliable` disarms it (a reliable send of the same key
+ * supersedes it). When a timer fires, `resend(payload, peerIds)` sends it on the reliable path.
+ * @param {(payload: any, peerIds: string[]) => void} resend
+ * @param {{delay?: number, setTimer?: (fn: () => void, ms: number) => any, clearTimer?: (t: any) => void}} [opts]
+ */
+export function createKeyframer(resend, opts = {}) {
+ const delay = opts.delay ?? KEYFRAME_MS;
+ const setTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
+ const clearTimer = opts.clearTimer ?? ((t) => clearTimeout(t));
+ /** key -> {timer, payload, peers} @type {Map}>} */
+ const pending = new Map();
+ const stats = { armed: 0, sent: 0 };
+ return {
+ stats,
+ /** @param {string} key @param {any} payload @param {Iterable} peerIds */
+ noteFast(key, payload, peerIds) {
+ const held = pending.get(key);
+ if (held) clearTimer(held.timer);
+ const peers = new Set(peerIds);
+ const entry = { payload, peers, timer: null };
+ entry.timer = setTimer(() => {
+ if (pending.get(key) !== entry) return;
+ pending.delete(key);
+ stats.sent++;
+ resend(entry.payload, [...entry.peers]);
+ }, delay);
+ pending.set(key, entry);
+ stats.armed++;
+ },
+ /** @param {string} key */
+ noteReliable(key) {
+ const held = pending.get(key);
+ if (!held) return;
+ clearTimer(held.timer);
+ pending.delete(key);
+ },
+ /** a peer left: it needs no keyframes @param {string} peerId */
+ dropPeer(peerId) {
+ for (const entry of pending.values()) entry.peers.delete(peerId);
+ },
+ clear() {
+ for (const entry of pending.values()) clearTimer(entry.timer);
+ pending.clear();
+ },
+ size: () => pending.size
+ };
+}
+
+/**
+ * The channels themselves, one per peer. `open(peerId, conn, onMessage)` creates the negotiated
+ * channel on `conn.peerConnection` (idempotent per RTCPeerConnection; a newer conn replaces an
+ * older one's channel). `trySend` answers false whenever the reliable path must carry it.
+ */
+export function createFastLinks() {
+ /** peerId -> {dc, pc} @type {Map} */
+ const links = new Map();
+ const stats = { opened: 0, sent: 0, received: 0, tooBig: 0, congested: 0, sendErrors: 0, parseErrors: 0 };
+ /** @param {string} peerId */
+ function close(peerId) {
+ const link = links.get(peerId);
+ if (!link) return;
+ links.delete(peerId);
+ try {
+ link.dc.close();
+ } catch {}
+ }
+ return {
+ stats,
+ /**
+ * @param {string} peerId @param {any} conn a PeerJS DataConnection
+ * @param {(data: any, via: 'fast') => void} onMessage @returns {boolean} true when a channel exists now
+ */
+ open(peerId, conn, onMessage) {
+ const pc = conn?.peerConnection;
+ if (!pc || typeof pc.createDataChannel !== 'function') return false;
+ const held = links.get(peerId);
+ if (held && held.pc === pc && held.dc.readyState !== 'closed') return true;
+ if (held) close(peerId);
+ let dc;
+ try {
+ dc = pc.createDataChannel(FAST_CHANNEL_LABEL, {
+ ordered: false,
+ maxRetransmits: 0,
+ negotiated: true,
+ id: FAST_CHANNEL_ID
+ });
+ } catch {
+ return false;
+ }
+ const link = { dc, pc };
+ links.set(peerId, link);
+ dc.onopen = () => {
+ stats.opened++;
+ };
+ dc.onclose = () => {
+ if (links.get(peerId) === link) links.delete(peerId);
+ };
+ dc.onmessage = (/** @type {MessageEvent} */ e) => {
+ if (typeof e.data !== 'string') return;
+ let data;
+ try {
+ data = JSON.parse(e.data);
+ } catch {
+ stats.parseErrors++;
+ return;
+ }
+ stats.received++;
+ onMessage(data, 'fast');
+ };
+ return true;
+ },
+ /** @param {string} peerId @returns {boolean} */
+ isOpen(peerId) {
+ return links.get(peerId)?.dc.readyState === 'open';
+ },
+ /** @param {string} peerId @param {string} text an already-serialized payload @returns {boolean} */
+ trySend(peerId, text) {
+ const link = links.get(peerId);
+ if (!link || link.dc.readyState !== 'open') return false;
+ if (text.length > FAST_MAX_BYTES) {
+ stats.tooBig++;
+ return false;
+ }
+ // a backlog: skip this pose (it counts as sent unreliably — a datagram lost early)
+ if (link.dc.bufferedAmount > FAST_MAX_BUFFERED) {
+ stats.congested++;
+ return true;
+ }
+ try {
+ link.dc.send(text);
+ stats.sent++;
+ return true;
+ } catch {
+ stats.sendErrors++;
+ return false;
+ }
+ },
+ close,
+ closeAll() {
+ for (const peerId of [...links.keys()]) close(peerId);
+ },
+ peers: () => [...links.keys()]
+ };
+}
diff --git a/src/lib/hdri/hdriRuntime.js b/src/lib/hdri/hdriRuntime.js
index 846075bf2..9c9e497e2 100644
--- a/src/lib/hdri/hdriRuntime.js
+++ b/src/lib/hdri/hdriRuntime.js
@@ -17,6 +17,8 @@ import { hdriOf, resolveHdriTier, isHeadsetUA, TIER_MAX_WIDTH, downsampleRGBA, i
import { bundledHdri } from './catalog.js';
import { skyEnv, envToneMapping, hdriStatus } from './skyEnv.js';
import { pageUrl } from '../export/exportBoot.js';
+import { fetchCached } from '../assetCache';
+import { APP_VERSION } from '../version';
import { qualityOverrides } from '../qualityGovernor';
import { isVRMode } from '../../stores/sceneStore';
import { hdriQuality } from './hdriPrefs.js';
@@ -72,9 +74,15 @@ async function fetchSource(src) {
if (held) return held;
const row = bundledHdri(src);
if (row) {
- const res = await fetch(pageUrl(row.file));
- if (!res.ok) throw new Error('HTTP ' + res.status + ' for ' + row.file);
- const got = { buffer: await res.arrayBuffer(), name: row.file };
+ // 60 Q6: through the local asset cache (kind `sky`). A bundled sky ships WITH the build, so
+ // its key carries the build's version: an updated file under the same name is fetched anew
+ let buffer;
+ try {
+ buffer = await fetchCached(pageUrl(row.file), { kind: 'sky', version: APP_VERSION });
+ } catch (e) {
+ throw new Error(String(/** @type {any} */ (e)?.message ?? e) + ' for ' + row.file);
+ }
+ const got = { buffer, name: row.file };
remember(sources, src, got);
return got;
}
diff --git a/src/lib/importOptimize.js b/src/lib/importOptimize.js
index 3384b0b68..47cd46cf5 100644
--- a/src/lib/importOptimize.js
+++ b/src/lib/importOptimize.js
@@ -14,6 +14,10 @@ import { retainOriginal } from './decimate';
import { generatedGroup } from './lodGroupCore';
import { parseGltfSceneSafe } from './ktx2Twins';
import { optimizeGltf, OptimizeUnsupported } from './gltfOptimize.js';
+// 60 Q6: the converted output is kept in the local asset cache, keyed by the source hash + settings
+import { readCached, writeCached, cachedEntry } from './assetCache';
+import { derivedKey } from './assetCacheCore.js';
+import { APP_VERSION } from './version';
import {
importDecision,
importOptions,
@@ -157,34 +161,53 @@ export async function maybeOptimizeImport(parsed, source) {
/** @type {any} */
let report = null;
let optimizedBytes = null;
+ // 60 Q6: the same file under the same settings converts ONCE per build — the textures'
+ // resize + KTX2 encode is the slow part of an import, and its output is a pure function of both
+ const engineOptions = importOptions(settings);
+ const convertedKey = runEngine
+ ? derivedKey('texture', hash, { options: engineOptions, v: APP_VERSION })
+ : null;
+ const converted = convertedKey ? await readCached(convertedKey) : null;
if (runEngine) {
const progressId = 'import-optimize-' + hash.slice(0, 12);
- showInfoToast(
- progressId,
- 'Optimizing "' + source.label + '"… you can keep working while it runs.',
- [],
- undefined,
- true
- );
+ if (!converted)
+ showInfoToast(
+ progressId,
+ 'Optimizing "' + source.label + '"… you can keep working while it runs.',
+ [],
+ undefined,
+ true
+ );
try {
- const out = await optimizeGltf(bytes, importOptions(settings), {
- onProgress: (p) => {
- if (p.step === 'textures' && p.total)
- showInfoToast(
- progressId,
- 'Optimizing "' +
- source.label +
- '": textures ' +
- Math.min(p.total, (p.done ?? 0) + 1) +
- ' / ' +
- p.total +
- '…',
- [],
- undefined,
- true
- );
- }
- });
+ const out = converted
+ ? {
+ bytes: new Uint8Array(converted),
+ report: cachedEntry(/** @type {string} */ (convertedKey))?.meta?.report ?? null
+ }
+ : await optimizeGltf(bytes, engineOptions, {
+ onProgress: (p) => {
+ if (p.step === 'textures' && p.total)
+ showInfoToast(
+ progressId,
+ 'Optimizing "' +
+ source.label +
+ '": textures ' +
+ Math.min(p.total, (p.done ?? 0) + 1) +
+ ' / ' +
+ p.total +
+ '…',
+ [],
+ undefined,
+ true
+ );
+ }
+ });
+ if (!converted && convertedKey)
+ void writeCached(convertedKey, out.bytes.slice().buffer, {
+ kind: 'texture',
+ type: 'model/gltf-binary',
+ meta: { report: out.report }
+ });
report = out.report;
optimizedBytes = out.bytes;
const gltfResult = await parseGltfSceneSafe(
@@ -254,7 +277,9 @@ export async function maybeOptimizeImport(parsed, source) {
optimizedFrom: original.hash,
optimizeReport: summaryForItem,
// 47 C2: the optimized copy names the same CC0 source as its original (Explorer Properties)
- ...(/** @type {any} */ (original).source ? { source: /** @type {any} */ (original).source } : {})
+ .../** @type {any} */ ((original).source
+ ? { source: /** @type {any} */ (original).source }
+ : {})
});
patchRecord(original.id, { optimizedHash: optimizedItem.hash });
}
diff --git a/src/lib/packCache.js b/src/lib/packCache.js
index 72f41132c..f2bebb62c 100644
--- a/src/lib/packCache.js
+++ b/src/lib/packCache.js
@@ -1,71 +1,77 @@
-import { writable, get } from 'svelte/store';
+import { derived, get } from 'svelte/store';
+import {
+ assetCacheIndex,
+ assetCacheReady,
+ readCached,
+ writeCached,
+ deleteCached,
+ fetchCached
+} from './assetCache';
+import { urlKey, urlOfKey } from './assetCacheCore.js';
-// 39 P5 — PACK DOWNLOADS: the files of pack items this device has fetched, kept in ONE
-// CacheStorage cache so the Explorer can say which items are downloaded (and how big they
-// are), "Delete cache" can free one item's bytes while the item stays in its pack, and
-// Storage can clear them all.
+// 39 P5 — PACK DOWNLOADS: the files of pack items this device has fetched, so the Explorer can
+// say which items are downloaded (and how big they are), "Delete cache" can free one item's bytes
+// while the item stays in its pack, and Storage can clear them all.
//
-// Before this every pack file went through the browser's HTTP cache alone — invisible to
-// the page, evictable at any time, and impossible to list or delete. CacheStorage is the
-// one store a page can both enumerate and size. The HTTP cache still sits underneath (we
-// cannot clear it), so "Delete cache" frees OUR copy: the next fetch goes to the network
-// (or whatever the browser still holds) and is cached here again.
+// 60 Q6: the bytes live in the LOCAL ASSET CACHE now (assetCache.js — OPFS, keyed by content
+// hash, LRU under the Settings ▸ Storage cap) as kind `pack`. This module keeps its API — every
+// caller (placement, scene pack refs, the Explorer badges, Storage) is unchanged — and is the
+// url-keyed VIEW of that cache. The 39 P5 CacheStorage copies are moved over once
+// (`migrateLegacy`) and that cache is deleted.
//
-// A LEAF: svelte/store only. LOCAL by construction — what a device has downloaded is a fact
-// about this machine; nothing here travels, saves or undoes. Every call degrades to a plain
-// network fetch where CacheStorage is missing (an exported game on file://, a non-secure
-// origin, a private window that refuses it), so no caller ever has to ask.
+// LOCAL by construction — what a device has downloaded is a fact about this machine; nothing
+// here travels, saves or undoes. Every call degrades to a plain network fetch where no store
+// works (an exported game on file://, a non-secure origin, a private window that refuses it).
+/** 39 P5's CacheStorage name — only read once, to migrate what it holds */
export const PACK_CACHE_NAME = 'tp-pack-files-v1';
-/** the size header written beside each cached response (a Response's body cannot be sized
- * without reading it, and the index must not read every blob on boot) */
-const BYTES_HEADER = 'x-tp-bytes';
-/** url -> cached bytes, for every file in the cache. The Explorer's badges and the Storage
- * row read it; `loadPackCacheIndex` fills it once, every put/delete keeps it current.
- * @type {import('svelte/store').Writable