Supra-Tex / hf_space /gallery_preview.js
UniLat3D's picture
Synchronize production exactly with verified Test Space
0105a52 verified
Raw History Blame Contribute Delete
21.7 kB
(() => {
if (window.__stGalleryInstalled) return;
window.__stGalleryInstalled = true;
const config = window.SupraSpaceGalleryConfig;
window.SupraGalleryLightBase = config.lights;
const models = new Map();
const englishMessage = (value, fallback) => typeof value === 'string' && value.length <= 500 &&
!/\p{Script=Han}/u.test(value) ? value : fallback;
// Cache original bytes only. Private entries live solely in this page and
// are keyed by the verified account; no browser storage or GPU scene is kept.
const cache = new Map(), cacheBudget = 128 * 1024 * 1024;
let cacheBytes = 0;
const privateCache = new Map(), transfers = new Map(), objectURLs = new Set();
const privateBudget = (navigator.deviceMemory && navigator.deviceMemory <= 4 ? 64 : 128) * 1024 * 1024;
let privateBytes = 0, account = '', accountEpoch = 0;
const aborted = () => new DOMException('Model request cancelled', 'AbortError');
function remember(url, bytes) {
if (bytes.byteLength > cacheBudget) return;
if (cache.has(url)) { cacheBytes -= cache.get(url).byteLength; cache.delete(url); }
while (cache.size >= 2 || cacheBytes + bytes.byteLength > cacheBudget) {
const oldest = cache.keys().next().value;
cacheBytes -= cache.get(oldest).byteLength; cache.delete(oldest);
}
cache.set(url, bytes); cacheBytes += bytes.byteLength;
}
function validate(bytes, expected) {
if (expected && bytes.byteLength !== expected) throw new Error('Incomplete 3D model. Please retry.');
if (bytes.byteLength < 12) throw new Error('Invalid 3D model.');
const header = new DataView(bytes);
if (header.getUint32(0, true) !== 0x46546c67 || header.getUint32(4, true) !== 2 ||
header.getUint32(8, true) !== bytes.byteLength) throw new Error('Invalid 3D model.');
return bytes;
}
function clearPrivate() {
++accountEpoch;
transfers.forEach(transfer => transfer.controller.abort()); transfers.clear();
privateCache.clear(); privateBytes = 0;
objectURLs.forEach(url => URL.revokeObjectURL(url)); objectURLs.clear();
}
function rememberPrivate(key, bytes) {
if (!account || bytes.byteLength > privateBudget) return;
if (privateCache.has(key)) { privateBytes -= privateCache.get(key).byteLength; privateCache.delete(key); }
while (privateCache.size >= 2 || privateBytes + bytes.byteLength > privateBudget) {
const oldest = privateCache.keys().next().value;
privateBytes -= privateCache.get(oldest).byteLength; privateCache.delete(oldest);
}
privateCache.set(key, bytes); privateBytes += bytes.byteLength;
}
function privatePath(value) {
try { const url = new URL(value, location.href); return url.origin === location.origin &&
/^\/api\/history\/jobs\/[A-Za-z0-9_-]+\/files\/asset\.glb$/.test(url.pathname) ? url : null; }
catch { return null; }
}
async function delivery(url, signal) {
const path = privatePath(url);
if (!path) return null;
try {
const response = await fetch(path.pathname.replace('/files/asset.glb', '/delivery'), {
credentials: 'same-origin', signal: AbortSignal.any([signal, AbortSignal.timeout(10000)])});
if (!response.ok) return null;
const value = await response.json();
const target = new URL(value.url, location.href);
// Only the authenticated server may supply a short-lived HTTPS location.
if ((value.direct && target.protocol === 'https:') || target.origin === location.origin) return {...value, url: target.href};
} catch (error) { if (signal.aborted) throw aborted(); }
return null;
}
async function download(model, url, gzip, expected, decodedBytes, signal, publicAsset) {
if (signal.aborted) throw aborted();
if (gzip && typeof DecompressionStream === 'undefined') throw new Error('This browser cannot decode the compressed 3D model.');
let received = 0, chunks = [], total = expected || 0, validator = '', resumable = false;
for (let attempt = 0; attempt < 3; attempt++) {
const controller = new AbortController();
const cancel = () => controller.abort(); signal.addEventListener('abort', cancel, {once: true});
const attemptDeadline = setTimeout(() => controller.abort(), 600000);
let timer, reader, invalid = false;
const arm = () => { clearTimeout(timer); timer = setTimeout(() => controller.abort(), 20000); };
try {
arm();
const offset = resumable && validator ? received : 0;
if (!offset) { received = 0; chunks = []; }
model.progress(received, total, attempt ? 'Reconnecting to model storage…' : 'Downloading 3D model');
const response = await fetch(url, {signal: controller.signal,
credentials: publicAsset ? 'omit' : 'same-origin', ...(publicAsset ? {mode: 'cors'} : {}),
headers: offset ? {'Range': `bytes=${offset}-`, 'If-Range': validator} : {}});
if (!response.ok) { invalid = response.status < 500 && ![408, 429].includes(response.status); throw new Error('The 3D model could not be downloaded.'); }
const encoding = response.headers.get('content-encoding');
const etag = response.headers.get('etag');
const nextValidator = etag && !etag.startsWith('W/') ? etag : response.headers.get('last-modified') || '';
if (offset && response.status === 206) {
const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') || '');
if (!range || Number(range[1]) !== offset || (total && Number(range[3]) !== total) ||
(nextValidator && validator !== nextValidator) || encoding) {
invalid = true; throw new Error('The model changed during transfer. Please reopen it.');
}
total = Number(range[3]);
} else {
if (response.status === 206) { invalid = true; throw new Error('Unexpected partial model response.'); }
received = 0; chunks = [];
total = encoding ? decodedBytes || 0 : expected || Number(response.headers.get('content-length')) || 0;
}
validator = nextValidator;
resumable = !encoding && !!validator;
// A raw .gz asset can resume its compressed bytes. Browser-decoded HTTP
// gzip has no reliable raw offset and is restarted instead.
const countedExpected = encoding ? 0 : expected;
reader = (response.body || new Blob([await response.arrayBuffer()]).stream()).getReader();
while (true) {
arm(); const {done, value} = await reader.read(); if (done) break;
received += value.byteLength;
if ((countedExpected && received > countedExpected) || received > 1024 * 1024 * 1024) {
invalid = true; throw new Error('The 3D model has an unexpected size.');
}
chunks.push(value); model.progress(received, total);
}
if ((countedExpected && received !== countedExpected) || (total && received !== total && !encoding))
throw new Error('Incomplete 3D model. Please retry.');
clearTimeout(timer);
let blob = new Blob(chunks); chunks = [];
const isGzip = gzip && !(encoding && /gzip/i.test(encoding));
const bytes = isGzip ? await new Response(blob.stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer() : await blob.arrayBuffer();
blob = null;
if (signal.aborted) throw aborted();
validate(bytes, decodedBytes || (!gzip && expected)); model.progress(received, received);
return bytes;
} catch (error) {
if (signal.aborted) throw aborted();
if (invalid || attempt === 2) throw new Error(controller.signal.aborted ?
'The model transfer stopped responding. Select it again to retry.' : error.message);
model.progress(received, total, 'Connection interrupted · Retrying download…');
await new Promise((resolve, reject) => {
const onAbort = () => { clearTimeout(wait); reject(aborted()); };
const wait = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, (attempt + 1) * 500);
signal.addEventListener('abort', onAbort, {once: true});
});
} finally {
clearTimeout(timer); clearTimeout(attemptDeadline); signal.removeEventListener('abort', cancel);
await reader?.cancel().catch(() => {});
}
}
}
function acquirePrivate(model) {
const key = account + '\0' + new URL(model.url, location.href).href;
const epoch = accountEpoch;
if (!account || !privatePath(model.url)) return {promise: Promise.reject(new Error('Please sign in to load this model.')), cancel() {}};
if (privateCache.has(key)) {
const bytes = privateCache.get(key); privateCache.delete(key); privateCache.set(key, bytes);
model.progress(bytes.byteLength, bytes.byteLength);
return {promise: Promise.resolve(bytes), cancel() {}};
}
let transfer = transfers.get(key);
if (!transfer) {
const controller = new AbortController(), listeners = new Set();
transfer = {controller, listeners, promise: null}; transfers.set(key, transfer);
const report = (...args) => listeners.forEach(listener => listener(...args));
transfer.promise = Promise.resolve().then(async () => {
const signed = await delivery(model.url, controller.signal);
let bytes;
try { bytes = await download({progress: report}, signed?.url || model.url, false,
Number(signed?.bytes) || model.bytes, 0, controller.signal, !!signed?.direct); }
catch (error) {
if (controller.signal.aborted || !signed?.direct) throw error;
report(0, model.bytes, 'Reconnecting to model storage…');
bytes = await download({progress: report}, model.url, false, model.bytes, 0, controller.signal, false);
}
if (epoch !== accountEpoch || controller.signal.aborted) throw aborted();
rememberPrivate(key, bytes); return bytes;
}).finally(() => { if (transfers.get(key) === transfer) transfers.delete(key); });
}
transfer.listeners.add(model.progress);
let cancelled = false;
return {promise: transfer.promise.then(bytes => { if (cancelled || epoch !== accountEpoch) throw aborted(); return bytes; }),
cancel() { cancelled = true; transfer.listeners.delete(model.progress); if (!transfer.listeners.size) transfer.controller.abort(); }};
}
window.SupraPrivateAssets = {
setAccount(value) { const next = String(value || ''); if (next !== account) { clearPrivate(); account = next; } },
clear: clearPrivate,
async download(url, filename = 'supra-tex.glb') {
const path = privatePath(url); if (!path || !account) throw new Error('Please sign in to download this model.');
const epoch = accountEpoch, key = account + '\0' + path.href;
let bytes = privateCache.get(key);
// Reuse an already active viewer transfer instead of sending a second GET.
const activeTransfer = transfers.get(key);
const listener = () => {};
if (!bytes && activeTransfer) {
activeTransfer.listeners.add(listener);
try { bytes = await activeTransfer.promise; } finally { activeTransfer.listeners.delete(listener); }
}
if (!bytes && !activeTransfer) {
const selected = [...document.querySelectorAll('.st-gallery-preview[data-model]')].find(node =>
new URL(node.dataset.model, location.href).href === path.href);
const expected = Number(selected?.dataset.modelBytes) || 0;
// A click can precede the lazy viewer bundle finishing its load. Start
// the same shared transfer now so the viewer joins it a moment later.
if (expected > 0 && expected <= privateBudget) {
const request = acquirePrivate({url:path.href, bytes:expected, progress:listener});
try { bytes = await request.promise; } finally { request.cancel(); }
}
}
if (epoch !== accountEpoch) throw aborted();
const anchor = document.createElement('a'); anchor.download = filename;
if (bytes) {
const blobURL = URL.createObjectURL(new Blob([bytes], {type: 'model/gltf-binary'}));
objectURLs.add(blobURL); anchor.href = blobURL;
setTimeout(() => { URL.revokeObjectURL(blobURL); objectURLs.delete(blobURL); }, 60000);
} else anchor.href = path.href; // Oversized models do not consume unbounded page memory.
anchor.hidden = true; document.body.append(anchor); anchor.click(); anchor.remove();
return !!bytes;
}
};
window.SupraPrivateAssets.setAccount(window.SupraPrivateAssetAccount?.() || '');
window.SupraAssetLoader = {
acquire(path) {
const id = path.split('/').pop().replace(/\.js$/, '');
const controller = new AbortController();
const model = models.get(id);
const signal = controller.signal;
return {
promise: Promise.resolve().then(async () => {
if (!model) throw new Error('Unknown 3D model.');
if (signal.aborted) throw aborted();
const publicAsset = model.publicAsset && model.encoding === 'gzip';
if (!publicAsset && privatePath(model.url)) {
const request = acquirePrivate(model);
signal.addEventListener('abort', request.cancel, {once: true});
try { return await request.promise; } finally { signal.removeEventListener('abort', request.cancel); }
}
if (publicAsset && cache.has(model.url)) {
const bytes = cache.get(model.url);
cache.delete(model.url); cache.set(model.url, bytes);
model.progress(model.bytes, model.bytes);
return bytes;
}
let bytes;
try {
bytes = await download(model, model.url, publicAsset, model.bytes, model.decodedBytes, signal, publicAsset);
} catch (error) {
if (signal.aborted) throw aborted();
if (error.name === 'AbortError') throw error;
// Old browsers or a CDN outage can still use the verified local example.
if (!publicAsset || !model.fallback) throw error;
const fallback = new URL(model.fallback, location.href);
if (fallback.origin !== location.origin) throw error;
bytes = await download(model, fallback.href, false, model.decodedBytes, model.decodedBytes, signal, false);
}
if (signal.aborted) throw aborted();
if (publicAsset) remember(model.url, bytes);
return bytes;
}).catch(error => {
if (signal.aborted) throw aborted();
throw error;
}),
cancel: () => controller.abort()
};
}
};
// The website bundle is loaded once. Models and HDRs remain lazy.
let library;
function loadLibrary() {
if (!library) library = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = config.bundle;
script.onload = resolve;
script.onerror = () => { library = null; script.remove(); reject(new Error('The 3D viewer could not load.')); };
document.head.append(script);
});
return library;
}
let active = null;
async function mount(root) {
const record = {root, url: root.dataset.model, mount: root.querySelector('.st-gallery-canvas'), viewer: null};
active = record;
const status = root.querySelector('.st-gallery-status');
const overlay = document.createElement('div');
overlay.className = 'st-gallery-loading';
overlay.setAttribute('role', 'status'); overlay.setAttribute('aria-live', 'polite');
overlay.innerHTML = '<span>Loading 3D model…</span><progress max="100" aria-label="3D model download progress"></progress>';
root.append(overlay);
const label = overlay.querySelector('span'), progress = overlay.querySelector('progress');
try {
await loadLibrary();
if (active !== record || !root.isConnected) return;
const id = root.dataset.case;
let lastProgress = 0, lastReceived = 0, progressStart = performance.now();
models.set(id, {url: root.dataset.model, bytes: Number(root.dataset.modelBytes) || 0,
encoding: root.dataset.modelEncoding, decodedBytes: Number(root.dataset.modelDecodedBytes) || 0,
publicAsset: root.dataset.modelPublic === 'true', fallback: root.dataset.modelFallback,
progress(received, total, phase = '') {
if (active !== record) return;
const now = performance.now();
if (!phase && received !== total && lastReceived > 0 && now - lastProgress < 120) return;
lastProgress = now; lastReceived = received;
const mb = (received / 1048576).toFixed(1);
const speed = now - progressStart > 1000 && received > 0 ? ` · ${(received / 1048576 / ((now - progressStart) / 1000)).toFixed(1)} MB/s` : '';
if (total > 0) {
const percent = Math.min(100, Math.floor(received / total * 100));
progress.value = percent;
label.textContent = phase || (percent === 100 ? 'Download complete · Preparing model and lighting…' :
`Loading 3D model · ${percent}% · ${mb} / ${(total / 1048576).toFixed(1)} MB${speed}`);
} else {
progress.removeAttribute('value');
label.textContent = phase || `Loading 3D model · ${mb} MB`;
}
}});
const viewer = record.viewer = window.SupraGallery3D.create(root.querySelector('.st-gallery-canvas'), {
onStatus: text => {
status.textContent = englishMessage(text, 'Preparing the 3D model…');
if (text.includes('Preparing')) label.textContent = 'Download complete · Parsing the model and preparing the surface…';
}
});
let selectedMode = 'pbr', appliedMode = 'pbr', modeVersion = 0;
function sync() {
const state = viewer.state();
root.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === selectedMode)));
}
async function selectMode(mode) {
const version = ++modeVersion;
const environment = mode.startsWith('shaded_') ? 'preview_' + mode.slice(7) : null;
selectedMode = mode; sync();
root.setAttribute('aria-busy', 'true');
try {
// The lighting request owns cancellation; a newer click always wins.
const light = environment || 'daylight';
if (environment || mode === 'pbr' || mode === 'clay') await viewer.setLighting(light);
if (version !== modeVersion || active !== record) return;
viewer.setMode(environment ? 'pbr' : mode === 'base_color' ? 'albedo' : mode);
appliedMode = mode;
status.textContent = 'Drag to rotate · Scroll to zoom';
} catch (error) {
if (version !== modeVersion || active !== record) return;
selectedMode = appliedMode;
status.textContent = englishMessage(error.message, 'This render mode could not load. Please try again.');
} finally {
if (version === modeVersion && active === record) { root.removeAttribute('aria-busy'); sync(); }
}
}
root.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => void selectMode(button.dataset.mode)));
root.querySelector('[data-reset]').addEventListener('click', () => viewer.reset());
await viewer.load(id);
if (active === record) { overlay.remove(); sync(); }
} catch (error) {
if (active === record) {
const message = englishMessage(error.message, 'The 3D model could not load. Please try again.');
status.textContent = message; label.textContent = message; progress.hidden = true;
}
}
}
function scan() {
const root = document.querySelector('#st-model .st-gallery-preview, #st-ovoxel .st-gallery-preview');
// Gradio can reuse the HTML root while changing its URL or replacing its
// children. Root identity alone does not identify the currently loaded model.
if (active?.root === root && active.url === root?.dataset.model &&
active.mount === root?.querySelector('.st-gallery-canvas') &&
(!active.viewer || active.mount.querySelector('canvas'))) return;
active?.viewer?.dispose(); active = null; models.clear();
root?.querySelectorAll('.st-gallery-loading').forEach(element => element.remove());
if (root) mount(root);
}
const app = document.getElementById('st-app');
if (app) {
new MutationObserver(scan).observe(app, {childList: true, subtree: true, attributes: true,
attributeFilter: ['data-model', 'data-model-bytes', 'data-model-encoding', 'data-model-decoded-bytes', 'data-model-public', 'data-model-fallback']});
// Show feedback as soon as a case is clicked, including the server lookup
// before Gradio delivers the next model URL.
app.addEventListener('click', event => {
const thumbnail = event.target.closest('#st-examples .thumbnail-item');
if (!thumbnail || (thumbnail.classList.contains('selected') && active?.viewer?.state().ready)) return;
const root = document.querySelector('#st-model .st-gallery-preview, #st-ovoxel .st-gallery-preview');
if (!root || root.querySelector('.st-gallery-loading')) return;
const pending = document.createElement('div'); pending.className = 'st-gallery-loading';
pending.innerHTML = '<span>Loading 3D model…</span><progress max="100" aria-label="3D model download progress"></progress>';
root.append(pending);
}, true);
scan();
}
window.addEventListener('pagehide', () => { active?.viewer?.dispose(); clearPrivate(); });
})();