Supra-Tex / hf_space /history_ui.js
UniLat3D's picture
Keep account rechecks quiet without resetting active sessions or tasks
6381524 verified
Raw History Blame Contribute Delete
104 kB
(() => {
const root = document.getElementById('st-app');
if (!root || root.dataset.historyInstalled) return;
root.dataset.historyInstalled = 'true';
const $ = id => document.getElementById(id);
const pending = job => ['queued', 'running'].includes(job?.status) ||
['queued', 'running'].includes(job?.texture_status) || ['queued', 'running'].includes(job?.export_status) || ['queued', 'running'].includes(job?.recover_status);
const stagedJob = job => job?.workflow === 'geometry_texture';
const texturesReady = job => !stagedJob(job) || job.texture_status === 'complete';
const terminal = state => ['native_build', 'models', 'workspace'].some(key =>
/^(failed|blocked)/.test(state?.[key]?.stage || ''));
const zeroGPU = state => state?.gpu_mode === 'zerogpu';
const modelsReady = window.SupraZeroGPU.modelsReady;
const ready = window.SupraZeroGPU.workspaceReady;
const {foregroundOperation, preparationState, operationReady, preparationError, formatProgress} = window.SupraZeroGPU;
const S = {identity: null, identityVersion: 0, deployment: null, jobs: new Map(), examples: new Map(),
cursor: null, revision: null, historyLoaded: false, examplesLoaded: false, examplesPromise: null,
historyError: '', historyFailures: 0, historyFailureSince: null,
pageLoading: false, selected: null, selectionVersion: 0, previewLoaded: null,
inputVersion: 0, source: null, uploadBusy: false, uploadState: null, submitBusy: false, expanded: false,
jobsTimer: null, startupTimer: null, noticeTimer: null, exportTimer: null, previewController: null, stopped: false,
refreshPromise: null, statusPromise: null, lastIdentityCheck: 0, submitKey: null, resumeOperation: null,
textureBusy: null, textureKeys: new Map(), textureErrors: new Map(),
localURL: null, silentAttempted: false, silentFrame: null, silentTimer: null,
authPopup: null, authTimer: null, authBootstrapTimer: null, authFlow: null, authVersion: 0, authBusy: false,
authFallback: false, identityPromise: null, authChecking: true, autoAuthAttempted: false, authMessage: ''};
S.verifyRequired = false; S.accountNeedsCheck = true; S.accountCheck = null; S.verificationEpoch = 0;
S.verificationRetries = 0; S.verificationRetryTimer = null; S.verificationDeferred = false;
S.visibilityEpoch = 0; S.verificationVisibilityEpoch = 0; S.verificationStartedHidden = false;
const accountWaiters = new Set();
const gpuDispatcher = window.SupraZeroGPU.createDispatcher({
identity: () => S.identity, version: () => S.identityVersion, stopped: () => S.stopped,
waitForAccount: signal => waitForVerifiedAccount(S.identityVersion, signal),
ready: record => operationReady(S.deployment, record),
prepare: (record, signal) => api('/gpu/prepare', {method: 'POST', json: {operation_id: record.gpu_operation_id}, signal}),
onPending() { void refreshStatus(); renderSelected(); },
onError(record, message) {
const state = S.uploadState;
if (state && currentUpload(state) && foregroundOperation(record) && state.uploadId === record.upload_id) {
S.uploadBusy = false; state.retryAction = 'gpu'; state.gpuRecord = record;
clearTimeout(state.timer); uploadLabel(state, 'failed', message, true);
void refreshUpload(state);
} else notice(message);
renderSelected(); void refreshJobs();
},
onComplete(record) {
const state = S.uploadState;
if (state && currentUpload(state) && foregroundOperation(record) && state.uploadId === record.upload_id) void refreshUpload(state);
void refreshJobs();
}
});
const resumeGPU = document.createElement('button');
resumeGPU.id = 'st-resume-gpu'; resumeGPU.type = 'button'; resumeGPU.hidden = true;
resumeGPU.textContent = 'Resume task';
resumeGPU.addEventListener('click', async () => {
const job = S.selected?.kind === 'job' && S.jobs.get(S.selected.id);
if (!job || !S.identity?.authenticated || S.resumeOperation) return;
const identityVersion = S.identityVersion, operation = job.gpu_operation_id;
S.resumeOperation = operation; renderSelected();
try {
// Reconnect to the current instance before using its readiness/progress.
const deployment = await refreshStatus();
if (identityVersion !== S.identityVersion || S.stopped || !S.identity?.authenticated) return;
const latest = S.jobs.get(job.id);
if (!deployment || !ready(deployment)) {
notice('The service is reconnecting. Please try Resume task again.'); return;
}
if (latest?.gpu_operation_id === operation) void gpuDispatcher.retry(latest);
} finally {
S.resumeOperation = null; renderSelected();
}
});
$('st-job-progress').append(resumeGPU);
const needsGPUResume = record => record?.gpu_mode === 'zerogpu' && record.gpu_operation_id &&
(!gpuDispatcher.requested(record.gpu_operation_id) || !!gpuDispatcher.failure(record.gpu_operation_id));
const taskPreparing = record => record?.gpu_mode === 'zerogpu' && record.gpu_operation_id &&
gpuDispatcher.requested(record.gpu_operation_id) && !gpuDispatcher.failure(record.gpu_operation_id) && !operationReady(S.deployment, record);
const startupBlocked = () => zeroGPU(S.deployment) ? /^(failed|blocked)/.test(S.deployment.workspace?.stage || '') : terminal(S.deployment);
const pollStartup = () => !startupBlocked() && (!ready(S.deployment) || gpuDispatcher.pending(record =>
!operationReady(S.deployment, record) && !preparationError(S.deployment, record)));
function preparationLabel(record) {
const state = preparationState(S.deployment, record);
if (!foregroundOperation(record) && state === S.deployment?.native_build) return 'Preparing environment';
const subject = foregroundOperation(record) ? 'background-removal model' : 'model weights';
return (state.stage === 'staging_local_weights' ? 'Downloading ' : state.stage === 'loading_models' ? 'Loading ' : 'Preparing ') + subject;
}
function preparationText(record) {
const progress = preparationState(S.deployment, record).startup_progress || {};
return preparationLabel(record) + ' · ' + formatProgress(progress).percent + '%';
}
function preparationMeter(meter, record) {
const progress = preparationState(S.deployment, record).startup_progress || {};
meter.max = 100;
if (taskPreparing(record)) meter.value = formatProgress(progress).percent;
else meter.removeAttribute('value');
}
// Gradio can finish its independent script callbacks in either order. Give
// a late viewer installation only the current cache scope, never credentials.
window.SupraPrivateAssetAccount = () => !S.stopped && S.identity?.authenticated ?
String(S.identity.account_key || S.identity.username || '') : '';
const statusNames = {queued: 'Queued', running: 'Generating', complete: 'Generation Complete', failed: 'Failed', interrupted: 'Interrupted'};
const zeroGPURecord = record => record?.gpu_mode === 'zerogpu' || !record?.gpu_mode && zeroGPU(S.deployment);
const leftInputBusy = () => S.submitBusy || !!S.textureBusy || !!S.resumeOperation || [...S.jobs.values()].some(job => {
const states = [job.status, job.texture_status];
if (states.includes('running')) return true;
return states.includes('queued') && (!zeroGPURecord(job) || !!job.gpu_operation_id &&
gpuDispatcher.requested(job.gpu_operation_id) && !gpuDispatcher.failure(job.gpu_operation_id));
});
const selectedGeometry = () => {
const job = S.selected?.kind === 'job' && S.jobs.get(S.selected.id);
return stagedJob(job) && job.status === 'complete' && S.source?.upload_id &&
S.source.upload_id === job.upload_id && Math.round(Number($('st-seed').value)) === Number(job.seed) ? job : null;
};
const textureFailure = job => (!['running', 'complete'].includes(job.texture_status) && S.textureErrors.get(job.id)) ||
(job.gpu_operation_kind === 'texture' && gpuDispatcher.failure(job.gpu_operation_id));
const textureProgressVisible = job => stagedJob(job) && job.status === 'complete' && !texturesReady(job) &&
(S.textureBusy === job.id || ['queued', 'running'].includes(job.texture_status) && !textureFailure(job));
const generationStatusName = job => stagedJob(job) ? ({queued: 'Preparing geometry', running: 'Generating geometry',
complete: 'Geometry ready', failed: 'Geometry failed', interrupted: 'Geometry interrupted'})[job.status] :
job.status === 'queued' && zeroGPURecord(job) ? 'Preparing' : statusNames[job.status];
const text = (id, value) => { $(id).textContent = value || ''; };
const show = (id, visible) => { $(id).hidden = !visible; };
const englishMessage = (value, fallback) => typeof value === 'string' && value.length <= 500 &&
!/\p{Script=Han}/u.test(value) ? value : fallback;
const generationProgressLabel = (value, fallback) => englishMessage(
value === 'Running the latest single-view pipeline' ? 'Starting generation…' : value, fallback);
const loadingCardMessage = 'Please wait for the models to finish loading.';
const workspaceMessage = 'Restoring saved uploads and history…';
let cardTip = null, cardTipTarget = null;
function hideCardTip() {
if (cardTip) cardTip.hidden = true;
cardTipTarget = null;
}
function gateCard(card) {
const loading = !ready(S.deployment);
card.disabled = !S.identity?.authenticated || loading || card.dataset.example !== undefined && leftInputBusy();
if (S.identity?.authenticated && loading && !terminal(S.deployment))
card.title = zeroGPU(S.deployment) || modelsReady(S.deployment) ? workspaceMessage : loadingCardMessage;
else card.removeAttribute('title');
}
function showCardTip(event) {
const card = event.target.closest('button[data-example],button[data-job-id]');
if (!card || ![loadingCardMessage, workspaceMessage].includes(card.title)) return hideCardTip();
if (!cardTip) {
cardTip = document.createElement('div'); cardTip.setAttribute('role', 'tooltip');
cardTip.id = 'st-card-loading-tip'; cardTip.textContent = loadingCardMessage;
cardTip.style.cssText = 'position:fixed;z-index:1000;max-width:260px;padding:9px 12px;border:1px solid #62583c;border-radius:8px;background:#28251e;color:#efe1bf;font-size:12px;line-height:1.5;text-align:center;box-shadow:0 4px 15px #0004;pointer-events:none';
root.append(cardTip);
}
cardTipTarget = card; cardTip.textContent = card.title; cardTip.hidden = false;
const box = card.getBoundingClientRect(), tip = cardTip.getBoundingClientRect();
cardTip.style.left = Math.max(8, Math.min(innerWidth - tip.width - 8, box.left + (box.width - tip.width) / 2)) + 'px';
cardTip.style.top = Math.max(8, Math.min(innerHeight - tip.height - 8, box.top - tip.height - 8)) + 'px';
}
const errorText = error => englishMessage(error?.message, 'Something went wrong. Please try again.');
const quietError = error => error?.name === 'AbortError';
const historyThumbnails = new Map(), localHistoryThumbnails = new Map();
const deletedHistory = new Set();
let deleteTarget = null, deleteBusy = false;
const thumbnailObserver = typeof IntersectionObserver === 'function' ? new IntersectionObserver(entries => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const state = historyThumbnails.get(entry.target.closest('[data-job-id]')?.dataset.jobId);
if (state?.image === entry.target) { thumbnailObserver.unobserve(entry.target); state.start(); }
}
}, {rootMargin: '300px'}) : null;
function disposeHistoryThumbnail(state) {
state.active = false;
clearTimeout(state.retryTimer); clearTimeout(state.loadTimer);
thumbnailObserver?.unobserve(state.image);
state.image.onload = state.image.onerror = null;
state.image.removeAttribute('src');
}
function clearHistoryThumbnails() {
for (const state of historyThumbnails.values()) disposeHistoryThumbnail(state);
historyThumbnails.clear(); thumbnailObserver?.disconnect();
for (const item of localHistoryThumbnails.values()) URL.revokeObjectURL(item.url);
localHistoryThumbnails.clear();
}
async function buildHistoryThumbnail(file) {
if (!file || typeof createImageBitmap !== 'function') return null;
const bitmap = await createImageBitmap(file);
try {
const scale = Math.min(1, 256 / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
canvas.getContext('2d').drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return await new Promise(resolve => canvas.toBlob(resolve, 'image/webp', 0.88));
} finally { bitmap.close(); }
}
async function prepareHistoryThumbnail(file) {
let timer;
try {
return await Promise.race([buildHistoryThumbnail(file).catch(() => null),
new Promise(resolve => { timer = setTimeout(() => resolve(null), 1500); })]);
} finally { clearTimeout(timer); }
}
function rememberHistoryThumbnail(id, blob, identityVersion) {
if (!blob?.size || blob.size > 256 * 256 * 4 || identityVersion !== S.identityVersion ||
S.stopped || !S.identity?.authenticated || !S.jobs.has(id)) return;
const previous = localHistoryThumbnails.get(id);
if (previous) URL.revokeObjectURL(previous.url);
localHistoryThumbnails.delete(id);
localHistoryThumbnails.set(id, {url: URL.createObjectURL(blob), bytes: blob.size});
let bytes = [...localHistoryThumbnails.values()].reduce((total, item) => total + item.bytes, 0);
while (localHistoryThumbnails.size > 48 || bytes > 4 * 1024 * 1024) {
const [oldId, item] = localHistoryThumbnails.entries().next().value;
bytes -= item.bytes; URL.revokeObjectURL(item.url); localHistoryThumbnails.delete(oldId);
}
}
function bindHistoryThumbnail(card, job) {
const image = card.querySelector('img'), placeholder = card.querySelector('.st-history-thumbnail-placeholder');
const fallback = '/api/history/jobs/' + encodeURIComponent(job.id) + '/files/thumb.webp';
let remote = fallback;
try {
const supplied = privateURL(job.thumbnail_url);
if (supplied && new URL(supplied).pathname === fallback) remote = supplied;
} catch {}
const local = localHistoryThumbnails.get(job.id), source = local?.url || remote;
const previous = historyThumbnails.get(job.id);
if (previous?.image === image && previous.source === source) return;
if (previous) disposeHistoryThumbnail(previous);
const state = {active: true, image, source, identityVersion: S.identityVersion, attempts: 0,
retryTimer: null, loadTimer: null, start: null};
historyThumbnails.set(job.id, state);
const current = () => state.active && historyThumbnails.get(job.id) === state &&
state.identityVersion === S.identityVersion && !S.stopped && S.identity?.authenticated && S.jobs.has(job.id);
const fail = () => {
if (!current()) return;
clearTimeout(state.loadTimer); state.loadTimer = null;
image.onload = image.onerror = null; image.removeAttribute('src');
image.style.opacity = '0'; placeholder.hidden = false;
if (local && localHistoryThumbnails.get(job.id) === local) {
URL.revokeObjectURL(local.url); localHistoryThumbnails.delete(job.id);
bindHistoryThumbnail(card, job); return;
}
const retry = state.attempts < 3;
card.dataset.thumbnailState = retry ? 'retrying' : 'unavailable';
placeholder.textContent = retry ? 'Loading image…' : 'Image unavailable';
if (retry) state.retryTimer = setTimeout(state.start, state.attempts === 1 ? 1000 : 3000);
};
state.start = () => {
if (!current() || state.loadTimer || state.attempts >= 3) return;
clearTimeout(state.retryTimer); state.retryTimer = null;
state.attempts++;
card.dataset.thumbnailState = state.attempts === 1 ? 'loading' : 'retrying';
placeholder.textContent = 'Loading image…'; placeholder.hidden = false; image.style.opacity = '0';
image.onload = () => {
if (!current()) return;
if (!image.naturalWidth) { fail(); return; }
clearTimeout(state.loadTimer); state.loadTimer = null;
image.onload = image.onerror = null; image.style.opacity = '1'; placeholder.hidden = true;
card.dataset.thumbnailState = 'ready';
};
image.onerror = () => { clearTimeout(state.loadTimer); state.loadTimer = null; fail(); };
state.loadTimer = setTimeout(() => { state.loadTimer = null; fail(); }, 10000);
const url = new URL(source, location.href);
if (!local && state.attempts > 1) url.searchParams.set('_st_thumb_attempt', String(state.attempts));
image.src = url.href;
};
card.dataset.thumbnailState = 'loading'; placeholder.textContent = 'Loading image…';
placeholder.hidden = false; image.style.opacity = '0';
if (!local && thumbnailObserver) thumbnailObserver.observe(image);
else state.start();
}
function pruneHistoryThumbnails(ids) {
for (const [id, state] of historyThumbnails) {
if (!ids.has(id)) { disposeHistoryThumbnail(state); historyThumbnails.delete(id); }
}
for (const [id, item] of localHistoryThumbnails) {
if (!S.jobs.has(id)) { URL.revokeObjectURL(item.url); localHistoryThumbnails.delete(id); }
}
}
function notice(message) {
text('st-native-notice', message); show('st-native-notice', true);
clearTimeout(S.noticeTimer); S.noticeTimer = setTimeout(() => show('st-native-notice', false), 5000);
}
function privateURL(value) {
if (!value) return '';
const url = new URL(value, location.href);
if (url.origin !== location.origin || !url.pathname.startsWith('/api/history/')) return '';
return url.href;
}
function authURL(value) {
try {
const url = new URL(value, location.href);
return url.origin === location.origin && ['/api/history/auth/start', '/login/huggingface'].includes(url.pathname) ? url.href : '';
} catch { return ''; }
}
function endSilent() {
clearTimeout(S.silentTimer); S.silentTimer = null;
S.silentFrame?.remove(); S.silentFrame = null;
}
function scheduleSilentTimeout() {
clearTimeout(S.silentTimer); S.silentTimer = null;
if (!S.silentFrame || document.hidden || S.stopped) return;
const check = S.accountCheck;
S.silentTimer = setTimeout(() => {
if (!document.hidden) void finishSilentAuth(false, check);
}, 30000);
}
function syncAccount() {
const checking = S.verifyRequired || S.accountNeedsCheck;
const signedIn = !!S.identity?.authenticated && !checking;
$('st-workspace').dataset.accountChecking = String(checking);
show('st-history-account', S.expanded && signedIn);
text('st-history-username', signedIn ? S.identity.username : '');
$('st-workspace').inert = !signedIn;
const dialog = $('st-auth-dialog');
// A routine return is a background identity check, not another sign-in.
// Keep the session's tasks in memory and the private surfaces masked.
const quietCheck = checking && !S.authBusy && (S.identity?.authenticated || S.authChecking || S.verificationDeferred);
show('st-account-check-status', quietCheck);
text('st-account-check-message', S.verificationDeferred ? S.authMessage || 'Connection interrupted. Retry to continue.' : 'Reconnecting…');
show('st-account-check-retry', quietCheck && S.verificationDeferred);
show('st-account-check-signin', quietCheck && S.verificationDeferred && S.verificationRetries >= 2);
if (quietCheck) { if (dialog.open) dialog.close(); return; }
if (signedIn) { if (dialog.open) dialog.close(); return; }
text('st-auth-message', S.authMessage || (S.authChecking || S.silentFrame ?
'Checking your Huggingface account…' : S.authBusy ?
'Complete sign-in in the Huggingface window. If it did not open, use the button below.' :
'Please sign in with your Huggingface account to continue.'));
show('st-auth-signin', !S.authChecking && !S.silentFrame);
text('st-auth-signin', S.authBusy ? 'Open sign-in window' : 'Sign in with Huggingface');
show('st-auth-cancel', S.authBusy);
if (!dialog.open) dialog.showModal();
}
function requireEntrySignIn() {
if (S.stopped || S.identity?.authenticated) return;
S.authChecking = false; syncAccount(); updateGate();
}
function beginAccountVerification(retry = false) {
endSilent(); ++S.verificationEpoch;
clearTimeout(S.verificationRetryTimer); S.verificationRetryTimer = null;
if (!retry) S.verificationRetries = 0;
S.verificationDeferred = false;
S.verifyRequired = true; S.accountNeedsCheck = false;
S.verificationVisibilityEpoch = S.visibilityEpoch; S.verificationStartedHidden = !!document.hidden;
S.accountCheck = randomAuthSecret(); S.silentAttempted = false;
S.authChecking = true; S.authMessage = '';
clearTimeout(S.jobsTimer); S.jobsTimer = null;
$('st-delete-dialog').close();
show('st-native-notice', false); hideCardTip();
for (const check of [...accountWaiters]) check();
syncAccount(); updateGate();
}
function accountVerificationFailed(message = '') {
endSilent();
clearTimeout(S.verificationRetryTimer); S.verificationRetryTimer = null;
S.verificationDeferred = false;
S.identity = {...S.identity, authenticated: false};
S.verifyRequired = false; S.accountNeedsCheck = false; S.accountCheck = null; S.authChecking = false;
S.authMessage = message;
clearPrivate(); updateGate(); syncAccount(); requireEntrySignIn(false);
}
function deferAccountVerification(message = '') {
endSilent();
S.authChecking = false; S.verificationDeferred = true; S.authMessage = message;
// A provider timeout says nothing about which HF account is signed in.
// Preserve pending work, but never reveal cached private content on failure.
syncAccount(); updateGate();
for (const check of [...accountWaiters]) check();
if (S.verificationRetries < 2 && !S.verificationRetryTimer && !document.hidden && !S.stopped) {
const epoch = S.verificationEpoch;
S.verificationRetryTimer = setTimeout(() => {
S.verificationRetryTimer = null;
if (epoch === S.verificationEpoch && !document.hidden && !S.stopped) void retryAccountVerification();
}, S.verificationRetries ? 5000 : 2500);
}
}
async function retryAccountVerification(manual = false) {
if (S.stopped || document.hidden || S.authBusy || S.authChecking) return;
if (manual) S.verificationRetries = 0;
else S.verificationRetries++;
beginAccountVerification(true);
const epoch = S.verificationEpoch;
try {
// The endpoint is fixed for this deployment. Re-reading /me before every
// return adds a round trip and cannot establish the current HF identity.
if (!authURL(S.identity?.silent_login_url)) await refreshIdentity();
if (!S.stopped && epoch === S.verificationEpoch) startSilentAuth();
} catch { if (!S.stopped && epoch === S.verificationEpoch) deferAccountVerification(); }
}
async function finishSilentAuth(authenticated = false, check = S.accountCheck, reason = 'unavailable') {
if (!check || check !== S.accountCheck || !S.verifyRequired || S.finishingCheck === check || S.stopped) return;
S.finishingCheck = check;
const epoch = S.verificationEpoch;
const current = () => !S.stopped && S.verifyRequired && check === S.accountCheck && epoch === S.verificationEpoch;
endSilent();
try {
if (!authenticated) {
if (['login_required', 'consent_required', 'access_denied'].includes(reason))
return accountVerificationFailed('Please sign in with your Huggingface account to continue.');
return deferAccountVerification();
}
await Promise.resolve(S.identityPromise).catch(() => {});
if (!current()) return;
const identity = await refreshIdentity({verificationKey: check});
if (identity.authenticated) return refreshJobs();
if (current()) deferAccountVerification();
} catch {
if (current()) deferAccountVerification();
} finally { if (S.finishingCheck === check) S.finishingCheck = null; }
}
function startSilentAuth() {
if (S.silentAttempted || (S.identity?.authenticated && !S.verifyRequired) || S.stopped) return;
S.silentAttempted = true;
let signedOut = false;
try { signedOut = sessionStorage.getItem('st-history-signed-out') === '1'; } catch {}
const url = authURL(S.identity?.silent_login_url);
if (!url || signedOut) { S.authFallback = true; accountVerificationFailed(); return; }
S.authChecking = true; S.authMessage = '';
const frame = S.silentFrame = document.createElement('iframe');
const verificationURL = new URL(url);
verificationURL.searchParams.set('refresh', '1');
verificationURL.searchParams.set('check', S.accountCheck);
frame.hidden = true; frame.title = 'Verify Huggingface account'; frame.src = verificationURL.href;
document.body.append(frame);
syncAccount();
scheduleSilentTimeout();
}
function endAccountConnection(closePopup = true) {
++S.authVersion; clearTimeout(S.authTimer); clearTimeout(S.authBootstrapTimer);
S.authTimer = null; S.authBootstrapTimer = null;
S.authFlow = null; S.authBusy = false;
if (closePopup) { try { S.authPopup?.close(); } catch {} S.authPopup = null; }
}
async function authRequest(path, json) {
const response = await fetch('/api/history/auth/' + path, {method: 'POST', credentials: 'same-origin', signal: AbortSignal.timeout(20000),
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(json)});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(englishMessage(data.detail, 'Could not connect your account. Please try again.'));
error.status = response.status; throw error;
}
return data;
}
function randomAuthSecret() {
const bytes = crypto.getRandomValues(new Uint8Array(32));
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function accountConnectionFailed(message) {
S.authChecking = false; S.authMessage = message;
endAccountConnection();
if (S.verifyRequired && S.identity?.authenticated) {
S.verificationRetries = 2;
deferAccountVerification(message); return;
}
text('st-account-help', message + ' Your image and settings are still here.');
show('st-account-help', true); updateGate(); syncAccount();
}
async function pollAccountConnection(version) {
const flow = S.authFlow;
if (version !== S.authVersion || !flow || S.stopped) return;
if (Date.now() >= flow.expiresAt) return accountConnectionFailed('Sign-in timed out. Please sign in again to continue.');
try {
const result = await authRequest('bridge/poll', {bridge_id: flow.bridge_id, poll_secret: flow.poll_secret});
if (version !== S.authVersion || S.stopped) return;
if (result.started) { flow.started = true; clearTimeout(S.authBootstrapTimer); }
if (result.status === 'failed') return accountConnectionFailed('Sign-in was not completed. Please sign in again to continue.');
if (result.status === 'complete') {
// The response sets an HttpOnly cookie in THIS embedded page's context.
// Neither opener access nor the popup's cookies are needed here.
await Promise.resolve(S.identityPromise).catch(() => {});
if (version !== S.authVersion || S.stopped) return;
const identity = await refreshIdentity({verificationKey: flow.bridge_id});
if (!identity.authenticated) return accountConnectionFailed('Your browser could not save the account connection. Please try again.');
notice('Signed in with Huggingface. You can now use Supra-Tex.');
await refreshJobs(); return;
}
} catch (error) {
if (version !== S.authVersion || S.stopped) return;
if (error.status && error.status < 500) return accountConnectionFailed('Sign-in expired. Please sign in again to continue.');
// A temporary network failure must not discard an ongoing sign-in.
}
if (version === S.authVersion && !S.stopped) S.authTimer = setTimeout(() => void pollAccountConnection(version), 1200);
}
function connectAccount(retry = false) {
if (S.identity?.authenticated && !S.verifyRequired) return;
clearTimeout(S.verificationRetryTimer); S.verificationRetryTimer = null;
S.verificationDeferred = false;
S.autoAuthAttempted = true;
S.silentAttempted = true;
if (S.authFlow && !retry) { try { S.authPopup?.focus(); } catch {} return; }
endAccountConnection();
try { sessionStorage.removeItem('st-history-signed-out'); } catch {}
endSilent(); S.authFallback = true; S.authChecking = false; S.authMessage = '';
// HF's outer page can force a null opener before the popup even loads.
// A one-use launch secret goes only in the URL fragment, which the approved
// bootstrap strips immediately. The separate claim secret stays here.
const bridge_id = randomAuthSecret(), start_secret = randomAuthSecret();
++S.verificationEpoch;
const fragment = new URLSearchParams({bridge_id, start_secret}).toString();
// Opening the popup can synchronously blur/hide its opener. Establish the
// sign-in state first so these events cannot schedule another verification.
S.authBusy = true;
for (const check of [...accountWaiters]) check();
try { S.authPopup = window.open('/api/history/auth/popup#' + fragment, '_blank', 'popup,width=600,height=720'); }
catch { return accountConnectionFailed('Please allow the sign-in window, then use Sign in to continue.'); }
S.authBusy = true;
show('st-account-help', false);
const version = S.authVersion;
S.authFlow = {bridge_id, started: false};
S.authBootstrapTimer = setTimeout(() => {
if (version === S.authVersion && !S.authFlow?.started) accountConnectionFailed('Please allow the sign-in window, then use Sign in to continue.');
}, 20000);
void authRequest('bridge', {bridge_id, start_secret}).then(flow => {
if (version !== S.authVersion || S.stopped) return;
Object.assign(S.authFlow, {poll_secret: flow.poll_secret,
expiresAt: Date.now() + Math.min(600, Number(flow.expires_in) || 600) * 1000});
void pollAccountConnection(version);
}).catch(() => {
if (version === S.authVersion && !S.stopped) accountConnectionFailed('Could not connect to Huggingface. Please sign in again to continue.');
});
updateGate(); syncAccount();
}
function clearPreview() {
$('st-output-panel').dataset.textureProgress = 'false';
show('st-texture-actions', false);
show('st-extract', true); show('st-export-note', true);
show('st-view-switch', false);
clearTimeout(S.exportTimer); S.exportTimer = null;
$('st-model').replaceChildren(); $('st-ovoxel').replaceChildren();
show('st-model', false); show('st-ovoxel', false); show('st-job-progress', false); show('st-queue-note', false);
show('st-native-empty', true); text('st-native-empty', 'Your 3D model will appear here.');
$('st-extract').disabled = true; show('st-download', false); $('st-download').removeAttribute('href');
$('st-extract').dataset.action = 'extract'; show('st-export-progress', false);
text('st-extract', 'Extract GLB');
text('st-export-note', 'Extract and download GLB.');
}
function clearPrivate() {
$('st-delete-dialog').close(); deleteTarget = null; deletedHistory.clear();
++S.identityVersion; ++S.selectionVersion; ++S.inputVersion;
for (const check of [...accountWaiters]) check();
gpuDispatcher.reset();
clearUploadPreview();
clearHistoryThumbnails();
S.previewController?.abort(); S.previewController = null;
window.SupraPrivateAssets?.setAccount('');
S.jobs.clear(); S.cursor = null; S.revision = null; S.historyLoaded = false; S.historyError = '';
S.historyFailures = 0; S.historyFailureSince = null; S.selected = null; S.previewLoaded = null;
S.refreshPromise = null; S.pageLoading = false;
S.source = null; S.uploadBusy = false; S.submitBusy = false; S.submitKey = null;
S.textureBusy = null; S.textureKeys.clear(); S.textureErrors.clear();
if (S.localURL) URL.revokeObjectURL(S.localURL);
S.localURL = null;
clearTimeout(S.jobsTimer); S.jobsTimer = null;
$('st-history-list').replaceChildren(); $('st-input-image').removeAttribute('src');
show('st-input-image', false); show('st-upload-placeholder', true); show('st-clear-image', false);
$('st-file-input').value = ''; clearPreview(); renderHistory();
}
function waitForVerifiedAccount(version, signal) {
// Keep an accepted upload response while the same account is rechecked.
// Never replay its POST or expose it to a different account.
return new Promise((resolve, reject) => {
let timer, finished = false;
const finish = error => {
if (finished) return;
finished = true;
clearTimeout(timer); accountWaiters.delete(check);
signal?.removeEventListener('abort', check);
document.removeEventListener('visibilitychange', check);
error ? reject(error) : resolve();
};
const check = () => {
if (version !== S.identityVersion || S.stopped || signal?.aborted)
finish(new DOMException('Request cancelled', 'AbortError'));
else if (!S.verifyRequired && !S.accountNeedsCheck) finish();
else if (document.hidden || !S.verifyRequired || S.verificationDeferred || S.authBusy) { clearTimeout(timer); timer = null; }
else if (!timer) timer = setTimeout(() => {
timer = null;
if (version === S.identityVersion && S.verifyRequired && !S.stopped && !S.authBusy) deferAccountVerification();
}, 45000);
};
accountWaiters.add(check); signal?.addEventListener('abort', check, {once: true});
document.addEventListener('visibilitychange', check);
check();
});
}
async function api(path, options = {}, authRetried = false) {
const privateCall = !/^\/(?:me|status|examples)(?:\?|$)/.test(path);
const identityVersion = S.identityVersion;
if (privateCall && (S.verifyRequired || S.accountNeedsCheck)) await waitForVerifiedAccount(identityVersion, options.signal);
const verificationEpoch = S.verificationEpoch, requestCSRF = S.identity?.csrf_token;
const headers = {...options.headers};
if (privateCall && S.identity?.account_key) headers['X-Supra-Account'] = S.identity.account_key;
if (options.json !== undefined) headers['Content-Type'] = 'application/json';
if ((options.method || 'GET') !== 'GET' && S.identity?.csrf_token) headers['X-CSRF-Token'] = S.identity.csrf_token;
const method = options.method || 'GET';
let response, data;
// Retry read-only metadata once. POSTs are never replayed automatically;
// submit/export already carry their explicit idempotency keys.
for (let attempt = 0; attempt < (method === 'GET' ? 2 : 1); attempt++) {
try {
const deadline = AbortSignal.timeout(method === 'GET' ? 15000 : path === '/upload' ? 120000 : 30000);
const signal = options.signal ? AbortSignal.any([options.signal, deadline]) : deadline;
response = await fetch('/api/history' + path, {
...options, credentials: 'same-origin', headers, signal,
body: options.json !== undefined ? JSON.stringify(options.json) : options.body
});
data = await response.json().catch(error => { if (signal.aborted || response.ok) throw error; return {}; });
if (response.status >= 500 && method === 'GET' && attempt === 0) continue;
break;
} catch (error) {
if (options.signal?.aborted || (privateCall && identityVersion !== S.identityVersion)) throw new DOMException('Request cancelled', 'AbortError');
if (method !== 'GET' || attempt) throw new Error('The connection timed out. Please try again.');
}
}
if (privateCall && (S.verifyRequired || S.accountNeedsCheck)) await waitForVerifiedAccount(identityVersion, options.signal);
if (privateCall && identityVersion !== S.identityVersion) throw new DOMException('Account changed', 'AbortError');
if (privateCall && S.identity?.authenticated && [401, 403].includes(response.status) &&
(verificationEpoch !== S.verificationEpoch || requestCSRF !== S.identity.csrf_token)) {
// A response from the cookie-reset window cannot sign out a newly verified
// session. Re-read metadata once; never replay a mutation automatically.
if (method === 'GET' && !authRetried) return api(path, options, true);
throw new Error('Your account was reconnected. Please retry this request.');
}
if (response.status === 401) {
S.identity = {authenticated: false}; S.authChecking = false;
S.authMessage = 'Your session has expired. Please sign in again to continue.';
endAccountConnection(); clearPrivate(); updateGate(); syncAccount();
throw new Error('Please authorize your Huggingface account to access your private history.');
}
if (response.status === 409 && (data.code === 'account_changed' || response.headers.get('X-Supra-Account-Changed') === '1')) {
clearPrivate(); beginAccountVerification(); startSilentAuth();
throw new DOMException('Account changed', 'AbortError');
}
if (!response.ok) {
const detail = typeof data.detail === 'string' ? data.detail : data.error;
const error = new Error(englishMessage(detail, 'Could not complete this request. Please try again.'));
error.status = response.status; error.code = data.code;
throw error;
}
const textureJob = method === 'POST' && /^\/jobs\/([^/]+)\/texture$/.exec(path);
if (textureJob && data?.id !== decodeURIComponent(textureJob[1]))
throw new Error('The texture response did not match this saved model. Please try again.');
// Reading history never starts CPU preparation or spends GPU quota. Only
// the user's creation/retry POST establishes intent for this page session.
gpuDispatcher.observe(data, {requested: method === 'POST' && path !== '/gpu/prepare'});
return data;
}
async function refreshIdentity(options = {}) {
if (S.identityPromise && S.identityPromiseEpoch !== S.verificationEpoch) S.identityPromise = null;
while (S.identityPromise) {
const result = await S.identityPromise;
if (!options.verificationKey || result?.verification_key === options.verificationKey) return result;
}
S.identityPromiseEpoch = S.verificationEpoch;
const promise = refreshIdentityInner(options).finally(() => { if (S.identityPromise === promise) S.identityPromise = null; });
S.identityPromise = promise;
return promise;
}
async function refreshIdentityInner({verificationKey} = {}) {
const identityVersion = S.identityVersion;
const verificationEpoch = S.verificationEpoch;
const identity = await api('/me');
if (identityVersion !== S.identityVersion || verificationEpoch !== S.verificationEpoch)
throw new DOMException('Account check superseded', 'AbortError');
if (verificationKey && (!identity.authenticated || identity.verification_key !== verificationKey))
throw new Error('The current account could not be verified.');
if (S.verifyRequired && !verificationKey) {
// A valid app cookie can still belong to the previous HF account. Do not
// publish it or request any private history before fresh OAuth completes.
if (!S.identity) S.identity = {...identity, authenticated: false};
else for (const key of ['login_url', 'silent_login_url', 'bridge_url', 'popup_url']) S.identity[key] = identity[key];
return {authenticated: false};
}
const previous = S.identity;
const preserve = previous && !previous.authenticated && identity.authenticated &&
(S.source?.local_file || S.source?.example) ? {source: S.source, seed: $('st-seed').value} : null;
if (previous && (previous.account_key !== identity.account_key ||
previous.username !== identity.username || previous.authenticated !== identity.authenticated)) clearPrivate();
S.identity = identity; S.lastIdentityCheck = Date.now();
if (verificationKey) {
S.verifyRequired = false; S.accountCheck = null;
// A silent check may finish while the user is in another HF tab and
// change accounts afterwards. Recheck on return before revealing data.
// An explicit popup handoff already verifies the account being selected.
S.accountNeedsCheck = !S.authFlow && (!!document.hidden || S.verificationStartedHidden ||
S.verificationVisibilityEpoch !== S.visibilityEpoch);
clearTimeout(S.verificationRetryTimer); S.verificationRetryTimer = null;
S.verificationDeferred = false; S.verificationRetries = 0;
for (const check of [...accountWaiters]) check();
}
window.SupraPrivateAssets?.setAccount(identity.authenticated ? identity.account_key || identity.username : '');
if (preserve?.source.local_file) stageLocalFile(preserve.source.local_file, preserve.seed);
else if (preserve?.source.example) {
selectExample(preserve.source.example); $('st-seed').value = preserve.seed;
}
if (identity.authenticated) {
S.authChecking = false; S.authMessage = '';
endSilent(); endAccountConnection(); show('st-account-help', false);
}
syncAccount(); updateGate(); renderHistory();
if (S.accountNeedsCheck && !document.hidden && !S.stopped) void resume();
// visibilitychange cancels the old startup timer. Resume preparation
// polling after verification even when the previous status read was reused.
if (verificationKey && !S.accountNeedsCheck && !document.hidden && pollStartup()) void refreshStatus();
if (identity.authenticated) void refreshJobs();
if (identity.authenticated && ready(S.deployment)) void activateWorkspace();
return identity;
}
function setInput(url, source, seed, inputVersion = S.inputVersion) {
if (inputVersion !== S.inputVersion) return;
if (S.uploadState && S.uploadState.inputVersion !== inputVersion) clearUploadPreview();
if (S.localURL && url !== S.localURL) { URL.revokeObjectURL(S.localURL); S.localURL = null; }
S.source = source; S.submitKey = null;
if (url) {
$('st-input-image').src = url;
show('st-input-image', true); show('st-upload-placeholder', false); show('st-clear-image', true);
}
if (seed !== undefined && seed !== null) $('st-seed').value = String(seed);
updateGate();
}
function updateGate() {
const signedIn = !!S.identity?.authenticated && !S.verifyRequired && !S.accountNeedsCheck, loading = !ready(S.deployment);
const inputBusy = leftInputBusy(), geometry = selectedGeometry();
$('st-input-panel').inert = inputBusy;
$('st-input-panel').dataset.busy = String(inputBusy);
$('st-input-panel').setAttribute('aria-disabled', String(inputBusy));
$('st-settings').inert = inputBusy;
$('st-settings').setAttribute('aria-disabled', String(inputBusy));
const imageFlow = S.uploadBusy || !!(S.uploadState && currentUpload(S.uploadState)) ||
!!(S.source?.foreground_required && !S.source.foreground_ready);
const failed = zeroGPU(S.deployment) ? /^(failed|blocked)/.test(S.deployment.workspace?.stage || '') : terminal(S.deployment);
const workspaceLoading = (zeroGPU(S.deployment) || modelsReady(S.deployment)) && loading;
if (!loading && $('st-native-notice').textContent.startsWith('Models are loading in the background.')) {
clearTimeout(S.noticeTimer); show('st-native-notice', false);
}
let reason = '';
if (!signedIn) reason = 'Please sign in with Huggingface to continue.';
else if (failed) reason = workspaceLoading ? 'Workspace preparation failed. Please try again later.' :
'Model loading failed. Please refresh and try again later.';
else if (loading) reason = workspaceLoading ? workspaceMessage : 'Models are loading in the background. You can generate once loading is complete.';
else if (S.authBusy) reason = 'Connecting your Huggingface account…';
else if (S.uploadBusy || S.source?.foreground_required && !S.source.foreground_ready) reason = '';
else if (!S.source) reason = 'Choose an example or upload an image.';
$('st-generate').disabled = inputBusy || !!geometry || !signedIn || loading || failed || !S.source || S.uploadBusy || S.authBusy ||
!!(S.source?.foreground_required && !S.source.foreground_ready);
for (const id of ['st-file-input', 'st-pick-image', 'st-change-image', 'st-clear-image', 'st-seed'])
$(id).disabled = inputBusy || !signedIn || loading || failed;
$('st-history-toggle').disabled = !signedIn;
$('st-history-more').disabled = !signedIn || S.pageLoading;
$('st-upload-retry').disabled = inputBusy || !signedIn || loading || failed || S.uploadBusy;
$('st-examples').inert = !signedIn || inputBusy;
$('st-examples').querySelectorAll('button[data-example]').forEach(gateCard);
$('st-history-list').inert = !signedIn;
$('st-history-list').querySelectorAll('button[data-job-id]').forEach(gateCard);
if (!loading || failed || !signedIn) hideCardTip();
$('st-output-panel').inert = !signedIn || loading || failed;
show('st-cancel-signin', S.authBusy);
text('st-generate', S.submitBusy ? 'Submitting…' : geometry ?
texturesReady(geometry) ? 'Geometry generated' : 'Geometry ready' : 'Generate geometry');
const explanatory = (loading || failed) && !imageFlow;
show('st-generate-hit', explanatory);
$('st-generate-hit').setAttribute('aria-label', reason);
$('st-generate-hit').title = reason;
text('st-gate-note', reason); show('st-gate-note', !!reason && !imageFlow);
requestAnimationFrame(() => { $('st-generate-hit').style.height = $('st-generate').offsetHeight + 'px'; });
}
async function refreshStatus() {
if (S.statusPromise) return S.statusPromise;
S.statusPromise = (async () => {
try {
const wasReady = ready(S.deployment); S.deployment = await api('/status'); updateGate();
gpuDispatcher.syncRuntime(S.deployment.runtime_id);
gpuDispatcher.advance(record => preparationError(S.deployment, record));
renderSelected();
if (S.uploadState && currentUpload(S.uploadState) && S.uploadState.uploadId) void refreshUpload(S.uploadState);
if (!wasReady && ready(S.deployment)) void activateWorkspace();
return S.deployment;
}
catch (error) { if (!S.deployment) text('st-gate-note', 'Connecting to the service…'); }
finally {
S.statusPromise = null; clearTimeout(S.startupTimer);
if (!S.stopped && pollStartup())
S.startupTimer = setTimeout(refreshStatus, document.hidden ? 20000 : 3000);
}
})();
return S.statusPromise;
}
function dateLabel(value) {
const date = new Date(Number(value) * 1000);
return Number.isFinite(date.getTime()) ? date.toLocaleString('en-US', {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}) : '';
}
function historyStatus(job) {
if (job.status !== 'complete') return {status: statusNames[job.status] ? job.status : 'running',
label: generationStatusName(job) || 'Preparing'};
if (stagedJob(job) && !texturesReady(job)) {
const labels = {queued: 'Preparing textures', running: 'Generating textures', failed: 'Texture failed', interrupted: 'Texture interrupted'};
return {status: labels[job.texture_status] ? job.texture_status : 'generation_complete',
label: labels[job.texture_status] || 'Geometry ready'};
}
// Generation completion makes the preview available. Export and recovery
// remain separate operations, and only a downloadable GLB completes the result.
const operations = [
{status: job.export_status, labels: {queued: zeroGPURecord(job) ? 'Preparing GLB' : 'GLB Queued', running: 'Extracting GLB',
failed: 'GLB Extraction Failed', interrupted: 'GLB Extraction Interrupted'}},
{status: job.recover_status, labels: {queued: zeroGPURecord(job) ? 'Preparing Preview' : 'Preview Recovery Queued', running: 'Recovering Preview',
failed: 'Preview Recovery Failed', interrupted: 'Preview Recovery Interrupted'}}
];
const operation = operations.find(item => item.status === 'running') ||
operations.find(item => item.status === 'queued') ||
operations.find(item => ['failed', 'interrupted'].includes(item.status));
if (operation) return {status: operation.status, label: operation.labels[operation.status]};
return stagedJob(job) || privateURL(job.download_url) ? {status: 'complete', label: 'Complete'} :
{status: 'generation_complete', label: 'Generation Complete'};
}
function renderHistory() {
const signedIn = !!S.identity?.authenticated, list = $('st-history-list');
const focused = list.contains(document.activeElement) ? document.activeElement : null;
const jobs = [...S.jobs.values()].sort((a, b) => Number(b.created_at) - Number(a.created_at));
text('st-history-note', !signedIn ? (S.silentFrame ? 'Verifying your Huggingface account…' :
'Your saved creations will appear here.') :
S.historyError || (!S.historyLoaded ? 'Loading history…' : jobs.length ? '' : 'Your creations will appear here.'));
show('st-history-note', !signedIn || !jobs.length || !!S.historyError);
const loading = signedIn && !S.historyLoaded && !S.historyError;
$('st-history-note').dataset.loading = String(loading);
$('st-history-note').setAttribute('role', 'status');
$('st-history-note').setAttribute('aria-live', 'polite');
list.setAttribute('aria-busy', String(signedIn && (!S.historyLoaded || S.pageLoading)));
show('st-history-more', signedIn && !!S.cursor);
$('st-history-more').disabled = !signedIn || S.pageLoading;
const existing = new Map([...list.children].map(node => [node.dataset.jobId, node]));
const fragment = document.createDocumentFragment();
for (const job of jobs) {
let item = existing.get(job.id), card = item?.querySelector('.st-history-card');
if (!item) {
item = document.createElement('div'); item.className = 'st-history-item'; item.dataset.jobId = job.id;
card = document.createElement('button'); card.type = 'button'; card.className = 'st-history-card'; card.dataset.jobId = job.id;
const image = document.createElement('img'); image.alt = 'Saved reference image'; image.loading = 'lazy'; image.decoding = 'async';
const frame = document.createElement('span'); frame.className = 'st-history-thumbnail';
frame.style.cssText = 'position:relative;display:block;width:100%;aspect-ratio:1;overflow:hidden;background:#1b1b19;border-radius:4px';
image.style.cssText = 'position:absolute;inset:0;display:block;width:100%;height:100%;opacity:0';
const placeholder = document.createElement('span'); placeholder.className = 'st-history-thumbnail-placeholder';
placeholder.style.cssText = 'position:absolute;inset:0;display:grid;place-items:center;padding:8px;font-size:11px;line-height:1.4;color:#aaa';
frame.append(image, placeholder);
const time = document.createElement('time'), badge = document.createElement('span'); badge.className = 'st-history-badge';
card.append(frame, time, badge);
const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'st-history-delete';
remove.dataset.deleteJob = job.id; remove.title = 'Delete history record';
remove.setAttribute('aria-label', 'Delete history record');
remove.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7M14 10v7"/></svg>';
item.append(card, remove);
}
bindHistoryThumbnail(card, job);
const status = historyStatus(job);
gateCard(card);
card.dataset.status = status.status;
card.setAttribute('aria-pressed', String(S.selected?.kind === 'job' && S.selected.id === job.id));
card.setAttribute('aria-label', `${status.label} · ${dateLabel(job.created_at)}`);
card.querySelector('time').textContent = dateLabel(job.created_at);
card.querySelector('.st-history-badge').textContent = status.label;
item.querySelector('.st-history-delete').disabled = !signedIn || deleteBusy;
fragment.append(item);
}
list.replaceChildren(fragment);
pruneHistoryThumbnails(new Set(jobs.map(job => job.id)));
if (focused && list.contains(focused)) focused.focus({preventScroll: true});
}
function forgetHistory(id) {
const job = S.jobs.get(id);
deletedHistory.add(id); S.jobs.delete(id);
if (S.selected?.kind === 'job' && S.selected.id === id) {
++S.selectionVersion; S.previewController?.abort(); S.previewController = null;
S.selected = null; S.previewLoaded = null; clearPreview();
// A newer upload can coexist with the previously selected result.
if (!leftInputBusy() && job?.upload_id && S.source?.upload_id === job.upload_id) {
++S.inputVersion; clearUploadPreview(); S.source = null; S.submitKey = null;
if (S.localURL) URL.revokeObjectURL(S.localURL); S.localURL = null;
$('st-input-image').removeAttribute('src'); $('st-file-input').value = '';
show('st-input-image', false); show('st-upload-placeholder', true); show('st-clear-image', false);
}
updateGate();
}
}
function confirmHistoryDelete(id) {
const job = S.jobs.get(id);
if (!job || !S.identity?.authenticated || deleteBusy) return;
if (pending(job)) return notice('Wait until this task finishes before deleting it.');
deleteTarget = id; show('st-delete-error', false);
$('st-delete-dialog').showModal(); $('st-delete-cancel').focus();
}
async function deleteHistory() {
if (!deleteTarget || deleteBusy) return;
const id = deleteTarget, identityVersion = S.identityVersion;
deleteBusy = true; $('st-delete-confirm').disabled = true;
text('st-delete-confirm', 'Deleting…'); show('st-delete-error', false); renderHistory();
try {
await api('/jobs/' + encodeURIComponent(id), {method: 'DELETE'});
if (identityVersion !== S.identityVersion) return;
forgetHistory(id); $('st-delete-dialog').close(); deleteTarget = null;
notice('History record deleted.');
} catch (error) {
if (identityVersion !== S.identityVersion || quietError(error)) return;
text('st-delete-error', errorText(error)); show('st-delete-error', true);
} finally {
deleteBusy = false; $('st-delete-confirm').disabled = false; text('st-delete-confirm', 'Delete');
renderHistory();
if (!$('st-delete-dialog').open && S.identity?.authenticated) $('st-history-toggle').focus();
}
}
function scheduleJobs() {
clearTimeout(S.jobsTimer); S.jobsTimer = null;
if (!S.stopped && !S.verifyRequired && !S.accountNeedsCheck && S.identity?.authenticated && (!S.historyLoaded || S.historyFailures || [...S.jobs.values()].some(pending)))
S.jobsTimer = setTimeout(() => refreshJobs().catch(() => {}),
document.hidden ? 20000 : S.historyLoaded ? 4000 : 5000);
}
async function refreshJobs(more = false) {
if (!S.identity?.authenticated || S.verifyRequired || S.accountNeedsCheck || S.stopped) return;
if (S.refreshPromise) return S.refreshPromise;
const identityVersion = S.identityVersion;
S.pageLoading = true; renderHistory();
S.refreshPromise = (async () => {
try {
const cursor = more ? S.cursor : null;
const incremental = !more && S.historyLoaded && S.revision !== null;
const result = await api(incremental ? '/jobs?since=' + encodeURIComponent(S.revision) :
'/jobs?limit=24' + (cursor ? '&cursor=' + encodeURIComponent(cursor) : ''));
if (identityVersion !== S.identityVersion) return;
S.historyError = ''; S.historyFailures = 0; S.historyFailureSince = null;
if (result.reset) { S.jobs.clear(); S.cursor = null; }
for (const id of result.deleted_ids || []) forgetHistory(id);
for (const job of result.items || []) if (!deletedHistory.has(job.id)) S.jobs.set(job.id, job);
if (more || !S.historyLoaded || result.reset) S.cursor = result.next_cursor || null;
if (!more && result.revision !== undefined) S.revision = result.revision;
S.historyLoaded = true;
if (!more && result.revision === undefined) {
const refreshed = new Set((result.items || []).map(job => job.id));
const olderPending = [...S.jobs.values()].filter(job => pending(job) && !refreshed.has(job.id));
// An export on an older page keeps running after the user selects
// something else. Refresh those jobs too, with bounded concurrency.
for (let offset = 0; offset < olderPending.length; offset += 4) {
const updates = await Promise.allSettled(olderPending.slice(offset, offset + 4).map(
job => api('/jobs/' + encodeURIComponent(job.id))));
if (identityVersion !== S.identityVersion) return;
for (const update of updates) if (update.status === 'fulfilled' && !deletedHistory.has(update.value.id)) S.jobs.set(update.value.id, update.value);
}
}
renderHistory(); renderSelected();
} catch (error) {
if (identityVersion === S.identityVersion && !quietError(error) && S.identity?.authenticated) {
S.historyFailures++;
S.historyFailureSince ??= Date.now();
// A cold-start 503 is a loading state. Preserve saved cards during
// transient refresh failures and keep recovery in the background.
const sustained = ready(S.deployment) && S.historyFailures >= 3 &&
Date.now() - S.historyFailureSince >= 30000;
S.historyError = sustained ? (S.historyLoaded ?
'History could not refresh. We will keep trying.' : 'History could not load. We will keep trying.') : '';
}
} finally {
if (identityVersion === S.identityVersion) {
S.refreshPromise = null; S.pageLoading = false; renderHistory(); updateGate(); scheduleJobs();
}
}
})();
return S.refreshPromise;
}
function renderSelected() {
updateGate();
if (S.selected?.kind !== 'job') return;
const job = S.jobs.get(S.selected.id); if (!job) return;
if (!ready(S.deployment)) {
if (job.status === 'complete') renderTexture(job);
return;
}
if (job.status === 'complete') {
if (!textureProgressVisible(job) && S.previewLoaded !== previewKey(job)) void loadPreview(job);
show('st-job-progress', false); renderTexture(job); renderExport(job); return;
}
$('st-output-panel').dataset.textureProgress = 'false';
show('st-texture-actions', false); show('st-extract', !stagedJob(job)); show('st-export-note', !stagedJob(job));
S.previewLoaded = null;
$('st-model').replaceChildren(); $('st-ovoxel').replaceChildren();
show('st-model', false); show('st-ovoxel', false); show('st-native-empty', false); show('st-job-progress', true);
$('st-extract').disabled = true; show('st-download', false);
show('st-export-progress', false); clearTimeout(S.exportTimer); S.exportTimer = null;
const progress = job.progress || {};
text('st-job-title', generationStatusName(job) || 'Preparing your model');
const position = progress.queue_position;
const gpuFailure = gpuDispatcher.failure(job.gpu_operation_id);
const resumeNeeded = needsGPUResume(job);
const resuming = !!S.resumeOperation && S.resumeOperation === job.gpu_operation_id;
$('st-job-progress').dataset.resumable = String(!!resumeNeeded);
show('st-resume-gpu', !!resumeNeeded);
resumeGPU.disabled = resuming;
text('st-resume-gpu', resuming ? 'Resuming…' : gpuFailure ? 'Retry task' : 'Resume task');
const detail = resuming ? 'Reconnecting to your task…' : gpuFailure ? gpuFailure : resumeNeeded ? 'Your task is saved. Resume when you are ready.' :
job.status === 'failed' || job.status === 'interrupted' ?
(job.status === 'interrupted' ? 'This generation was interrupted. You can generate it again.' :
englishMessage(job.error, 'This generation could not finish. You can try again.')) :
taskPreparing(job) ? preparationText(job) :
job.status === 'queued' && zeroGPURecord(job) ? (stagedJob(job) ? 'Starting geometry… Keep this page open.' : 'Starting generation… Keep this page open.') :
job.status === 'queued' && position !== undefined && position !== null ?
`Waiting in queue · Position ${position}` : generationProgressLabel(progress.label, 'Preparing your model…');
text('st-job-detail', detail);
show('st-queue-note', job.status === 'queued' && !zeroGPURecord(job));
$('st-job-progress').querySelector('progress').hidden = !!resumeNeeded || !['queued', 'running'].includes(job.status);
preparationMeter($('st-job-progress').querySelector('progress'), job);
const timing = !resumeNeeded && (taskPreparing(job) ? 'Only needed for the first generation after each Space startup.' :
progress.elapsed_seconds ? `${Math.floor(progress.elapsed_seconds)}s elapsed` : '');
text('st-job-time', timing); show('st-job-time', !!timing);
text('st-export-note', '');
}
function previewKey(job) {
const view = texturesReady(job) && privateURL(job.download_url) ? S.selected?.view || 'glb' : 'render';
return `${view}:${job.id}:${job.asset_id || ''}`;
}
async function loadPreview(job) {
const version = S.selectionVersion, inputVersion = S.inputVersion;
const key = previewKey(job);
if (S.previewLoaded === 'loading:' + key) return;
S.previewLoaded = 'loading:' + key;
S.previewController?.abort(); const controller = S.previewController = new AbortController();
clearPreview(); text('st-native-empty', 'Loading saved preview…');
try {
const view = key.split(':')[0];
const result = await api('/jobs/' + encodeURIComponent(job.id) + '/preview?view=' + view, {signal: controller.signal});
if (version !== S.selectionVersion || S.selected?.id !== job.id || S.previewController !== controller) return;
const current = S.jobs.get(job.id) || job;
if (previewKey(current) !== key) { S.previewLoaded = null; renderSelected(); return; }
const textureProgress = textureProgressVisible(current);
$('st-ovoxel').innerHTML = result.html || '';
show('st-ovoxel', !!result.html && !textureProgress); show('st-native-empty', !result.html && !textureProgress);
if (!result.html) text('st-native-empty', 'The saved preview is not available.');
const input = privateURL(result.display_url || job.display_url || result.input_url || job.input_url);
const original = privateURL(result.input_url || job.input_url);
if (input && (!leftInputBusy() || !S.source) && !S.source?.local_file && !S.source?.local_job)
setInput(input, {upload_id: result.upload_id || job.upload_id, input_url: original}, result.seed ?? job.seed, inputVersion);
if (result.download_url) current.download_url = result.download_url;
const model = $('st-ovoxel').querySelector('.st-gallery-preview[data-model]');
S.previewLoaded = result.view ? `${result.view}:${job.id}:${job.asset_id || ''}` :
model && privateURL(model.dataset.model) === privateURL(current.download_url) ?
`glb:${job.id}:${job.asset_id || ''}` : `render:${job.id}:${job.asset_id || ''}`;
renderHistory(); renderTexture(current); renderExport(current);
} catch (error) {
if (quietError(error) || version !== S.selectionVersion || S.previewController !== controller) return;
S.previewLoaded = null;
if (textureProgressVisible(S.jobs.get(job.id) || job)) return;
text('st-native-empty', 'Could not load this preview. Select it again to retry.');
notice(errorText(error));
}
}
function renderTexture(job) {
const visible = stagedJob(job) && job.status === 'complete' && !texturesReady(job);
const central = visible && textureProgressVisible(job);
$('st-output-panel').dataset.textureProgress = String(!!central);
show('st-texture-actions', visible);
if (!visible) return;
const previewReady = S.previewLoaded === previewKey(job) && !!$('st-ovoxel').innerHTML;
show('st-model', false); show('st-ovoxel', !central && previewReady);
show('st-native-empty', !central && !previewReady); show('st-job-progress', false);
const active = ['queued', 'running'].includes(job.texture_status);
const busy = S.textureBusy === job.id;
const gpuFailure = textureFailure(job);
const resumeNeeded = job.texture_status === 'queued' && job.gpu_operation_kind === 'texture' && needsGPUResume(job);
const failed = ['failed', 'interrupted'].includes(job.texture_status);
const preparing = active && taskPreparing(job);
const progress = job.texture_progress || {};
show('st-texture-note', !active && !busy && !failed && !gpuFailure);
show('st-texture-progress', active || busy || failed || !!gpuFailure);
text('st-texture-title', busy ? 'Preparing textures' : failed || gpuFailure ? 'Texture failed' :
job.texture_status === 'running' ? 'Generating textures' : 'Preparing textures');
text('st-texture-detail', busy ? 'Connecting to your task…' : gpuFailure || (resumeNeeded ?
'Your geometry is ready. Resume textures when you are ready.' : failed ?
englishMessage(job.texture_error, 'Texture generation could not finish. Retry textures to continue.') : preparing ?
preparationText(job) : job.texture_status === 'running' ? generationProgressLabel(progress.label, 'Generating textures…') :
'Starting textures… Keep this page open.'));
const meter = $('st-texture-progress').querySelector('progress');
meter.hidden = !busy && (!active || !!resumeNeeded || !!gpuFailure);
preparationMeter(meter, job);
const timing = !preparing && !resumeNeeded && !busy && progress.elapsed_seconds ? `${Math.floor(progress.elapsed_seconds)}s elapsed` : '';
text('st-texture-time', timing); show('st-texture-time', !!timing);
show('st-texture', !active || !!resumeNeeded || busy);
$('st-texture').disabled = busy || active && !resumeNeeded || !S.identity?.authenticated || !ready(S.deployment);
text('st-texture', busy ? 'Connecting…' : gpuFailure || failed ? 'Retry textures' : resumeNeeded ? 'Resume textures' : 'Generate textures');
}
async function startTexture() {
const job = S.selected?.kind === 'job' && S.jobs.get(S.selected.id);
if (!job || !stagedJob(job) || job.status !== 'complete' || texturesReady(job) ||
!S.identity?.authenticated || S.textureBusy || $('st-texture').disabled) return;
const identityVersion = S.identityVersion;
S.textureErrors.delete(job.id);
S.textureBusy = job.id; updateGate(); renderTexture(job);
try {
const deployment = await refreshStatus();
if (identityVersion !== S.identityVersion || S.stopped || !S.identity?.authenticated) return;
if (!deployment || !ready(deployment)) throw new Error('The service is reconnecting. Please try again.');
const current = S.jobs.get(job.id);
if (!current || texturesReady(current)) return;
if (current.texture_status === 'queued') {
if (current.gpu_operation_kind === 'texture' && needsGPUResume(current)) void gpuDispatcher.retry(current);
return;
}
if (current.texture_status === 'running') return;
if (!S.textureKeys.has(job.id)) S.textureKeys.set(job.id, crypto.randomUUID());
const updated = await api('/jobs/' + encodeURIComponent(job.id) + '/texture',
{method: 'POST', json: {idempotency_key: S.textureKeys.get(job.id)}});
if (identityVersion !== S.identityVersion || S.stopped) return;
S.textureKeys.delete(job.id); S.jobs.set(updated.id, updated);
renderHistory(); scheduleJobs();
} catch (error) {
if (identityVersion === S.identityVersion && !quietError(error)) {
const message = errorText(error); S.textureErrors.set(job.id, message);
if (S.selected?.kind !== 'job' || S.selected.id !== job.id) notice(message);
}
} finally {
if (identityVersion === S.identityVersion) {
S.textureBusy = null;
updateGate();
if (S.selected?.kind === 'job' && S.selected.id === job.id) renderSelected();
}
}
}
function renderExport(job) {
clearTimeout(S.exportTimer); S.exportTimer = null;
const allowed = texturesReady(job);
show('st-extract', allowed); show('st-export-note', allowed);
if (!allowed) {
$('st-extract').disabled = true; show('st-download', false); show('st-export-progress', false);
show('st-view-switch', false); text('st-export-note', ''); return;
}
const exporting = ['queued', 'running'].includes(job.export_status);
const gpuFailure = job.export_status === 'queued' && gpuDispatcher.failure(job.gpu_operation_id);
const resumeNeeded = job.export_status === 'queued' && needsGPUResume(job);
const url = privateURL(job.download_url);
show('st-view-switch', !!url && job.status === 'complete');
const view = previewKey(job).split(':')[0];
$('st-view-switch').querySelectorAll('[data-result-view]').forEach(button =>
button.setAttribute('aria-pressed', String(button.dataset.resultView === view)));
$('st-extract').disabled = (!url && exporting && !resumeNeeded) || job.status !== 'complete' || !S.identity?.authenticated || !ready(S.deployment);
$('st-extract').dataset.action = url ? 'download' : 'extract';
text('st-extract', url ? 'Download GLB' : gpuFailure ? 'Retry extraction' : resumeNeeded ? 'Resume GLB extraction' : exporting ?
job.export_status === 'queued' ? zeroGPURecord(job) ? 'Preparing GLB…' : 'GLB extraction queued…' : 'Extracting GLB…' : 'Extract GLB');
show('st-download', false);
if (url) $('st-download').href = url; else $('st-download').removeAttribute('href');
show('st-export-progress', exporting && !url);
text('st-export-note', url ? 'Your GLB is ready to download.' : gpuFailure ? gpuFailure : resumeNeeded ?
'Your extraction request is saved. Resume it when you are ready.' : taskPreparing(job) ? preparationText(job) : exporting ?
'You can return to My History while extraction continues.' :
job.export_status === 'failed' ? englishMessage(job.export_error, 'Extraction failed. Click Extract GLB to try again.') : 'Extract and download GLB.');
if (!exporting || url) return;
const preparing = taskPreparing(job), queued = job.export_status === 'queued';
const progress = (preparing ? preparationState(S.deployment, job).startup_progress : job.export_progress) || {};
const position = progress.queue_position;
text('st-export-label', preparing ? preparationText(job) : resumeNeeded ? 'Extraction paused' :
queued ? zeroGPURecord(job) ? 'Preparing GLB… Keep this page open.' : `Waiting for extraction${position ? ' · Queue position ' + position : ''}` :
englishMessage(progress.label, 'Preparing your GLB…'));
const completed = Math.max(0, Number(progress.completed) || 0), total = Math.max(0, Number(progress.total) || 0);
const meter = $('st-export-meter'), details = [];
if (preparing) {
meter.value = formatProgress(progress).percent;
meter.setAttribute('aria-valuetext', formatProgress(progress).percent + '%');
} else if (total && !queued) {
meter.value = Math.min(100, completed / total * 100);
meter.setAttribute('aria-valuetext', `${Math.min(completed, total)} of ${total} ${progress.unit === 'bytes' ? 'bytes' : 'stages'} complete`);
details.push(progress.unit === 'bytes' ?
`${(completed / 1048576).toFixed(1)} / ${(total / 1048576).toFixed(1)} MB` :
`${Math.min(completed, total)} / ${total} stages`);
} else { meter.removeAttribute('value'); meter.removeAttribute('aria-valuetext'); }
const transferred = Number(progress.bytes_completed), transferTotal = Number(progress.bytes_total);
if (!preparing && transferTotal > 0 && progress.unit !== 'bytes') details.push(
`${(Math.max(0, transferred || 0) / 1048576).toFixed(1)} / ${(transferTotal / 1048576).toFixed(1)} MB transferred`);
job._exportProgressSeenAt ||= Date.now();
const elapsed = Math.max(0, Number(progress.elapsed_seconds) || 0) +
(progress.clock === 'running' ? Math.floor((Date.now() - job._exportProgressSeenAt) / 1000) : 0);
if (elapsed) details.push(`${Math.floor(elapsed)}s elapsed`);
if (!preparing && queued && !zeroGPURecord(job)) details.push('Extraction shares the generation queue.');
text('st-export-detail', details.join(' · '));
S.exportTimer = setTimeout(() => {
if (S.selected?.kind === 'job' && S.selected.id === job.id) renderExport(S.jobs.get(job.id) || job);
}, 1000);
}
function selectJob(id, keepInput = false) {
if (!S.identity?.authenticated || !ready(S.deployment)) return;
const job = S.jobs.get(id); if (!job) return;
const preserveReference = leftInputBusy() && !!S.source;
++S.selectionVersion; if (!preserveReference) ++S.inputVersion;
S.previewController?.abort();
S.selected = {kind: 'job', id}; S.previewLoaded = null; clearPreview();
const input = privateURL(job.display_url || job.input_url) || `/api/history/jobs/${encodeURIComponent(id)}/files/input.png`;
const original = privateURL(job.input_url) || `/api/history/jobs/${encodeURIComponent(id)}/files/input.png`;
const currentInput = keepInput && $('st-input-image').getAttribute('src');
if (!preserveReference && currentInput && S.source) {
// A submission already has a visible reference, including examples.
// Keep it while the newly saved private files become available.
const source = {...S.source}; delete source.example;
setInput(currentInput, {...source, local_job: id, upload_id: job.upload_id,
input_url: original}, job.seed);
} else if (!preserveReference) setInput(input, {upload_id: job.upload_id, input_url: original}, job.seed);
renderHistory(); renderSelected();
if (S.expanded && matchMedia('(max-width:900px)').matches) expandHistory(false);
}
function selectExample(name) {
if (leftInputBusy() || !S.identity?.authenticated || !ready(S.deployment)) return;
const example = S.examples.get(name); if (!example) return;
++S.selectionVersion; ++S.inputVersion; S.previewController?.abort();
S.selected = {kind: 'example', id: name}; S.previewLoaded = null;
setInput(example.image_url, {example: name}, 42);
clearPreview();
$('st-model').innerHTML = example.preview_html || '';
show('st-model', !!example.preview_html); show('st-native-empty', !example.preview_html);
$('st-examples').querySelectorAll('[data-example]').forEach(item => item.classList.toggle('selected', item.dataset.example === name));
renderHistory();
}
async function loadExamples() {
if (S.stopped || S.examplesLoaded) return;
if (S.examplesPromise) return S.examplesPromise;
S.examplesPromise = (async () => {
try {
const result = await api('/examples'), fragment = document.createDocumentFragment();
for (const example of result.items || []) {
S.examples.set(example.name, example);
const button = document.createElement('button'); button.type = 'button'; button.className = 'thumbnail-item'; button.dataset.example = example.name;
button.setAttribute('aria-label', 'Use input example: ' + example.title);
const image = document.createElement('img'); image.src = example.thumbnail_url; image.alt = example.title; image.loading = 'lazy'; image.decoding = 'async';
button.append(image); fragment.append(button);
}
$('st-examples').replaceChildren(fragment);
S.examplesLoaded = true;
updateGate();
} catch (error) { notice('Examples could not load. Please refresh to retry.'); }
finally { S.examplesPromise = null; }
})();
return S.examplesPromise;
}
async function activateWorkspace() {
if (!S.identity?.authenticated || S.verifyRequired || S.accountNeedsCheck || !ready(S.deployment) || S.stopped) return;
await Promise.allSettled([loadExamples(), refreshJobs()]);
if (S.source?.local_file && !S.source.upload_id && S.source.foreground_required &&
(!S.uploadState || S.uploadState.phase === 'waiting_service'))
void upload(S.source.local_file);
}
function clearUploadPreview() {
const state = S.uploadState;
if (state) {
state.active = false; clearTimeout(state.timer); state.controller.abort();
if (state.resultURL) URL.revokeObjectURL(state.resultURL);
}
S.uploadState = null; S.uploadBusy = false;
show('st-upload-status', false); show('st-upload-retry', false);
text('st-upload-label', '');
}
function currentUpload(state) {
return state.active && S.uploadState === state && state.inputVersion === S.inputVersion &&
state.identityVersion === S.identityVersion && S.identity?.authenticated && !S.stopped;
}
function uploadLabel(state, phase, label, retry = false) {
if (!currentUpload(state)) return;
state.phase = phase;
$('st-upload-status').dataset.state = phase;
text('st-upload-label', label); show('st-upload-status', true);
show('st-upload-retry', retry && (!!state.uploadId || state.retryAction === 'upload'));
updateGate();
}
function scheduleUploadStatus(state, delay = document.hidden ? 6000 : 1500) {
clearTimeout(state.timer);
if (currentUpload(state)) state.timer = setTimeout(() => void refreshUpload(state), delay);
}
function uploadReadFailed(state, error) {
if (!currentUpload(state) || state.appliedResultKey || state.applyPromise || quietError(error)) return;
if (state.resultURL) { URL.revokeObjectURL(state.resultURL); state.resultURL = null; }
state.failures++;
state.failureSince ??= Date.now();
if (state.failures < 3 || Date.now() - state.failureSince < 30000) {
S.uploadBusy = true;
uploadLabel(state, 'checking', 'Preparing image…');
scheduleUploadStatus(state, Math.min(8000, state.failures * 2000));
} else {
S.uploadBusy = false; state.retryAction = 'status';
uploadLabel(state, 'failed', 'Background-removal progress is temporarily unavailable. Your image is saved. Please retry.', true);
}
}
async function applyUploadResult(result, state) {
if (!currentUpload(state)) return;
if (!result.upload_id || state.uploadId && state.uploadId !== result.upload_id)
throw new Error('The uploaded image could not be verified.');
// One upload state belongs to one account and input version. Repeated
// status/dispatcher notifications must not re-download its applied cutout.
const foreground = result.foreground;
const resultKey = foreground?.status === 'complete' ? JSON.stringify([
result.upload_id, result.files?.['foreground.png']?.version || result.foreground_url
]) : null;
if (state.appliedResultKey && (!resultKey || state.appliedResultKey === resultKey)) return;
if (state.applyPromise) {
await state.applyPromise;
return applyUploadResult(result, state);
}
state.uploadId = result.upload_id;
state.gpuRecord = result;
S.source = {...S.source, upload_id: result.upload_id, uploaded: true};
if (!foreground) {
// Retain compatibility with saved uploads and the previous API version.
S.source.foreground_required = false; S.uploadBusy = false;
show('st-upload-status', false); updateGate(); return;
}
S.source.foreground_required = true; S.source.foreground_ready = false;
if (['queued', 'running'].includes(foreground.status)) {
const gpuFailure = gpuDispatcher.failure(result.gpu_operation_id);
if (gpuFailure) {
S.uploadBusy = false; state.retryAction = 'gpu'; state.gpuRecord = result;
clearTimeout(state.timer); uploadLabel(state, 'failed', gpuFailure, true); return;
}
state.failures = 0; state.failureSince = null; S.uploadBusy = true;
uploadLabel(state, foreground.status, taskPreparing(result) ? 'Loading model…' : foreground.status === 'queued' ?
'Removing background… Keep this page open.' : 'Removing background…');
scheduleUploadStatus(state); return;
}
if (['failed', 'interrupted'].includes(foreground.status)) {
S.uploadBusy = false; state.retryAction = 'foreground'; state.retryKey = null;
uploadLabel(state, 'failed', englishMessage(foreground.error, 'Background removal failed. Please retry.'), true); return;
}
if (foreground.status !== 'complete') throw new Error('Background removal status is unavailable.');
const applying = (async () => {
S.uploadBusy = true; uploadLabel(state, 'loading', 'Loading processed image…');
const url = privateURL(result.foreground_url);
const expected = '/api/history/uploads/' + encodeURIComponent(state.uploadId) + '/files/foreground.png';
if (!url || new URL(url).pathname !== expected) throw new Error('The background-removed image is unavailable.');
const response = await fetch(url, {credentials: 'same-origin', cache: 'no-store',
signal: AbortSignal.any([state.controller.signal, AbortSignal.timeout(30000)])});
if (!response.ok) throw new Error('The background-removed image could not load.');
const blob = await response.blob();
if (!currentUpload(state)) return;
if (!blob.size || blob.size > 8 * 1024 * 1024 || !blob.type.startsWith('image/'))
throw new Error('The background-removed image is invalid.');
state.resultURL = URL.createObjectURL(blob);
const image = new Image(); image.src = state.resultURL;
let decodeTimer;
try {
await Promise.race([image.decode(), new Promise((_, reject) => {
decodeTimer = setTimeout(() => reject(new Error('The processed image could not load. Please retry.')), 15000);
})]);
} finally { clearTimeout(decodeTimer); }
if (!currentUpload(state)) return;
const previous = S.localURL;
S.localURL = state.resultURL; state.resultURL = null;
if (previous) URL.revokeObjectURL(previous);
S.uploadBusy = false; state.failures = 0; state.failureSince = null; state.retryKey = null;
state.appliedResultKey = resultKey; clearTimeout(state.timer); state.timer = null;
setInput(S.localURL, {...S.source, foreground_required: true, foreground_ready: true,
foreground_file: blob}, undefined, state.inputVersion);
uploadLabel(state, 'complete', 'Background removed.');
})();
state.applyPromise = applying;
try { await applying; }
finally { if (state.applyPromise === applying) state.applyPromise = null; }
}
async function refreshUpload(state) {
if (!currentUpload(state) || !state.uploadId || state.appliedResultKey) return;
if (state.refreshPromise) return state.refreshPromise;
state.refreshPromise = (async () => {
try {
const result = await api('/uploads/' + encodeURIComponent(state.uploadId), {signal: state.controller.signal});
await applyUploadResult(result, state);
} catch (error) { uploadReadFailed(state, error); }
finally { state.refreshPromise = null; }
})();
return state.refreshPromise;
}
async function retryUploadPreview() {
const state = S.uploadState;
if (leftInputBusy() || !state || !currentUpload(state) || S.uploadBusy || !ready(S.deployment)) return;
if (state.retryAction === 'upload') return upload(S.source?.local_file);
state.appliedResultKey = null;
if (state.retryAction === 'gpu' && state.gpuRecord) {
S.uploadBusy = true;
uploadLabel(state, 'queued', 'Preparing image…');
void gpuDispatcher.retry(state.gpuRecord); scheduleUploadStatus(state); return;
}
state.failures = 0; state.failureSince = null; S.uploadBusy = true;
uploadLabel(state, 'queued', 'Preparing image…');
if (state.retryAction !== 'foreground') return refreshUpload(state);
state.retryKey ||= crypto.randomUUID();
try {
const result = await api('/uploads/' + encodeURIComponent(state.uploadId) + '/foreground', {
method: 'POST', json: {idempotency_key: state.retryKey}, signal: state.controller.signal});
await applyUploadResult(result, state);
} catch (error) { uploadReadFailed(state, error); }
}
async function upload(file) {
if (!file || leftInputBusy()) return;
if (!S.identity?.authenticated || !ready(S.deployment) || S.stopped) return;
if (file.size > 20 * 1024 * 1024) return notice('Please upload an image smaller than 20 MiB.');
const serviceWaits = S.source?.local_file === file && S.uploadState?.phase === 'waiting_service' ?
S.uploadState.serviceWaits : 0;
clearUploadPreview();
const version = ++S.inputVersion;
const state = S.uploadState = {active: true, inputVersion: version, identityVersion: S.identityVersion,
controller: new AbortController(), timer: null, uploadId: null, resultURL: null, failures: 0,
serviceWaits, phase: 'uploading'};
if (S.localURL) URL.revokeObjectURL(S.localURL);
S.localURL = URL.createObjectURL(file);
setInput(S.localURL, {local_file: file, foreground_required: true, foreground_ready: false}, undefined, version);
S.uploadBusy = true; S.submitKey = null; updateGate();
uploadLabel(state, 'uploading', 'Uploading image…');
try {
const form = new FormData(); form.append('file', file);
const result = await api('/upload', {method: 'POST', body: form, signal: state.controller.signal});
await applyUploadResult(result, state);
} catch (error) {
if (currentUpload(state) && !quietError(error)) {
if (state.uploadId) uploadReadFailed(state, error);
else if (error.status === 503 && error.code === 'upload_not_started') {
// Only this explicit pre-body rejection is safe to resume. Never
// replay a timed-out POST whose server-side outcome is unknown.
S.uploadBusy = false; state.serviceWaits++; state.retryAction = 'upload';
S.deployment = {...S.deployment, workspace: {stage: 'starting'}};
uploadLabel(state, state.serviceWaits <= 3 ? 'waiting_service' : 'failed',
state.serviceWaits <= 3 ? 'Preparing image…' :
'The service is still preparing. Your image is kept here. Please retry when ready.', state.serviceWaits > 3);
void refreshStatus();
}
else {
S.uploadBusy = false;
state.retryAction = 'upload';
uploadLabel(state, 'failed', errorText(error), true);
}
}
} finally { if (currentUpload(state)) { $('st-file-input').value = ''; updateGate(); } }
}
function stageLocalFile(file, seed) {
if (leftInputBusy()) return;
clearUploadPreview();
++S.inputVersion; S.uploadBusy = false;
if (S.localURL) URL.revokeObjectURL(S.localURL);
S.localURL = URL.createObjectURL(file);
setInput(S.localURL, {local_file: file, foreground_required: true, foreground_ready: false}, seed);
$('st-file-input').value = '';
if (S.identity?.authenticated && ready(S.deployment)) void upload(file);
}
async function submit() {
if (leftInputBusy() || selectedGeometry() || $('st-generate').disabled) return;
if (!S.identity?.authenticated) return connectAccount();
const source = {...S.source}, seed = Math.round(Number($('st-seed').value));
if (!Number.isFinite(seed)) return notice('Please enter a valid seed.');
const identityVersion = S.identityVersion, thumbnail = prepareHistoryThumbnail(source.foreground_file || source.local_file);
S.submitBusy = true; const submissionKey = S.submitKey ||= crypto.randomUUID(); updateGate();
const selectionVersion = ++S.selectionVersion;
const inputVersion = S.inputVersion;
S.previewController?.abort(); S.selected = {kind: 'submitting'}; S.previewLoaded = null;
clearPreview(); text('st-native-empty', 'Submitting your generation…');
try {
if (source.local_file && !source.upload_id) {
const form = new FormData(); form.append('file', source.local_file);
const uploaded = await api('/upload', {method: 'POST', body: form});
source.upload_id = uploaded.upload_id;
if (inputVersion === S.inputVersion) {
setInput(S.localURL, {upload_id: uploaded.upload_id, local_file: source.local_file, uploaded: true}, seed, inputVersion);
S.submitKey = submissionKey;
}
} else if (!source.upload_id && !source.example && source.input_url) {
const original = privateURL(source.input_url);
if (!original) throw new Error('Could not restore the saved input. Please upload it again.');
const response = await fetch(original, {credentials: 'same-origin', signal: AbortSignal.timeout(30000)});
if (!response.ok) throw new Error('Could not restore the saved input. Please upload it again.');
const form = new FormData(); form.append('file', await response.blob(), 'input.png');
const uploaded = await api('/upload', {method: 'POST', body: form}); source.upload_id = uploaded.upload_id;
}
const job = await api('/submit', {method: 'POST', json: {upload_id: source.upload_id, example: source.example, seed, idempotency_key: submissionKey}});
const localThumbnail = await thumbnail;
if (identityVersion !== S.identityVersion || S.stopped || !S.identity?.authenticated) return;
S.jobs.set(job.id, job); S.submitKey = null;
rememberHistoryThumbnail(job.id, localThumbnail, identityVersion);
if (selectionVersion === S.selectionVersion && inputVersion === S.inputVersion) selectJob(job.id, true);
else {
if (selectionVersion === S.selectionVersion && S.selected?.kind === 'submitting') {
S.selected = null; clearPreview(); text('st-native-empty', 'Your generation is saved in My History.');
}
renderHistory();
}
scheduleJobs();
} catch (error) {
if (identityVersion === S.identityVersion && !S.stopped && !quietError(error)) {
notice(errorText(error));
if (selectionVersion === S.selectionVersion && S.selected?.kind === 'submitting') {
S.selected = null; clearPreview();
text('st-native-empty', 'Generation could not be submitted. Please try again.');
}
void refreshStatus();
}
}
finally { if (identityVersion === S.identityVersion) S.submitBusy = false; updateGate(); }
}
async function extract() {
if (S.selected?.kind !== 'job' || $('st-extract').disabled) return;
const job = S.jobs.get(S.selected.id); if (!job) return;
if (!texturesReady(job)) return;
if (job.export_status === 'queued' && needsGPUResume(job)) {
void gpuDispatcher.retry(job); renderExport(job); return;
}
const url = privateURL(job.download_url);
if (url) {
try {
if (window.SupraPrivateAssets) await window.SupraPrivateAssets.download(url);
else { $('st-download').href = url; $('st-download').click(); }
} catch (error) { if (!quietError(error)) notice(errorText(error)); }
return;
}
$('st-extract').disabled = true;
job._exportKey ||= crypto.randomUUID();
try {
const updated = await api('/jobs/' + encodeURIComponent(job.id) + '/export', {method: 'POST', json: {idempotency_key: job._exportKey}});
S.jobs.set(updated.id, updated); renderHistory();
if (S.selected?.id === job.id) renderSelected(); scheduleJobs();
} catch (error) { if (!quietError(error)) notice(errorText(error)); if (S.selected?.id === job.id) renderExport(job); }
}
function positionHistory() {
if (!S.expanded) return;
const panel = $('st-history-panel'), mobile = matchMedia('(max-width:900px)').matches;
show('st-history-backdrop', mobile); panel.setAttribute('aria-modal', String(mobile));
if (mobile) { panel.style.cssText = ''; return; }
const viewer = $('st-output-panel').getBoundingClientRect(), examples = $('st-examples-panel').getBoundingClientRect();
Object.assign(panel.style, {left: viewer.left + 'px', top: viewer.top + 'px',
width: Math.max(240, examples.left - viewer.left - 12) + 'px', height: viewer.height + 'px'});
}
function expandHistory(expanded) {
if (!S.identity?.authenticated && expanded) return;
S.expanded = expanded; const panel = $('st-history-panel');
panel.classList.toggle('expanded', expanded);
$('st-history-toggle').setAttribute('aria-expanded', String(expanded));
$('st-history-toggle').setAttribute('aria-label', expanded ? 'Collapse My History' : 'Expand My History');
text('st-history-toggle', expanded ? '→' : '←');
if (expanded) { panel.setAttribute('role', 'dialog'); positionHistory(); }
else { panel.style.cssText = ''; panel.removeAttribute('role'); panel.removeAttribute('aria-modal'); show('st-history-backdrop', false); }
syncAccount();
}
$('st-generate-hit').addEventListener('click', () => {
root.querySelector('#st-generate-control').dataset.notice = 'true';
notice($('st-gate-note').textContent);
});
$('st-generate-hit').addEventListener('mouseenter', () => {
root.querySelector('#st-generate-control').dataset.notice = 'true';
});
$('st-generate-hit').addEventListener('mouseleave', () => delete root.querySelector('#st-generate-control').dataset.notice);
const pick = () => { if (!leftInputBusy() && ready(S.deployment) && S.identity?.authenticated) $('st-file-input').click(); };
$('st-pick-image').addEventListener('click', pick); $('st-change-image').addEventListener('click', pick);
$('st-file-input').addEventListener('change', event => void upload(event.target.files[0]));
$('st-upload').addEventListener('dragover', event => event.preventDefault());
$('st-upload').addEventListener('drop', event => { event.preventDefault(); void upload(event.dataTransfer.files[0]); });
for (const eventName of ['dragover', 'drop']) document.addEventListener(eventName, event => {
if (leftInputBusy() && Array.from(event.dataTransfer?.types || []).includes('Files')) event.preventDefault();
}, true);
$('st-clear-image').addEventListener('click', () => {
if (leftInputBusy() || !ready(S.deployment)) return;
clearUploadPreview();
++S.inputVersion; S.source = null; S.uploadBusy = false; S.submitKey = null;
if (S.localURL) URL.revokeObjectURL(S.localURL); S.localURL = null;
$('st-input-image').removeAttribute('src'); show('st-input-image', false); show('st-upload-placeholder', true); show('st-clear-image', false); updateGate();
});
$('st-upload-retry').addEventListener('click', () => void retryUploadPreview());
$('st-seed').addEventListener('input', () => {
if (!leftInputBusy()) { S.submitKey = null; updateGate(); }
});
$('st-generate').addEventListener('click', () => void submit());
const cancelSignIn = () => accountConnectionFailed('Sign-in cancelled. Please sign in to continue.');
$('st-cancel-signin').addEventListener('click', cancelSignIn);
$('st-auth-cancel').addEventListener('click', cancelSignIn);
$('st-auth-signin').addEventListener('click', () => connectAccount(true));
$('st-auth-dialog').addEventListener('cancel', event => event.preventDefault());
$('st-extract').addEventListener('click', () => void extract());
$('st-texture').addEventListener('click', () => startTexture());
$('st-view-switch').addEventListener('click', event => {
const button = event.target.closest('[data-result-view]');
if (!button || S.selected?.kind !== 'job') return;
const job = S.jobs.get(S.selected.id);
if (!job || job.status !== 'complete' || !privateURL(job.download_url)) return;
const view = button.dataset.resultView;
if (!['render', 'glb'].includes(view) || previewKey(job) === `${view}:${job.id}:${job.asset_id || ''}`) return;
S.selected.view = view;
++S.selectionVersion; S.previewLoaded = null;
renderSelected();
});
$('st-examples').addEventListener('click', event => { const item = event.target.closest('[data-example]'); if (item) selectExample(item.dataset.example); });
$('st-history-list').addEventListener('click', event => {
const remove = event.target.closest('[data-delete-job]');
if (remove) { event.stopPropagation(); confirmHistoryDelete(remove.dataset.deleteJob); return; }
const item = event.target.closest('button[data-job-id]'); if (item) selectJob(item.dataset.jobId);
});
$('st-delete-confirm').addEventListener('click', () => void deleteHistory());
$('st-delete-cancel').addEventListener('click', () => { if (!deleteBusy) { $('st-delete-dialog').close(); deleteTarget = null; } });
$('st-delete-dialog').addEventListener('cancel', event => { if (deleteBusy) event.preventDefault(); else deleteTarget = null; });
for (const container of [$('st-examples'), $('st-history-list')]) {
container.addEventListener('pointerover', showCardTip);
container.addEventListener('pointerout', event => { if (!cardTipTarget?.contains(event.relatedTarget)) hideCardTip(); });
container.addEventListener('scroll', hideCardTip, {passive: true});
}
window.addEventListener('scroll', hideCardTip, {passive: true});
window.addEventListener('resize', hideCardTip);
$('st-history-more').addEventListener('click', () => void refreshJobs(true));
$('st-history-toggle').addEventListener('click', () => expandHistory(!S.expanded));
$('st-queue-history-link').addEventListener('click', event => {
event.preventDefault(); expandHistory(true);
requestAnimationFrame(() => {
if (S.selected?.kind !== 'job') return;
const card = [...$('st-history-list').children].find(item => item.dataset.jobId === S.selected.id);
card?.scrollIntoView({block: 'nearest', inline: 'nearest'}); card?.querySelector('.st-history-card')?.focus({preventScroll: true});
});
});
$('st-history-backdrop').addEventListener('click', () => expandHistory(false));
document.addEventListener('keydown', event => {
if (!S.expanded || $('st-delete-dialog').open) return;
if (event.key === 'Escape') { expandHistory(false); $('st-history-toggle').focus(); }
if (event.key === 'Tab' && matchMedia('(max-width:900px)').matches) {
const targets = [...$('st-history-panel').querySelectorAll('button:not([hidden]):not(:disabled)')].filter(node => node.offsetParent);
const first = targets[0], last = targets.at(-1);
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}
});
$('st-history-signout').addEventListener('click', () => {
try { sessionStorage.setItem('st-history-signed-out', '1'); } catch {}
endAccountConnection(); endSilent();
S.identity = {authenticated: false}; S.authFallback = true; S.authChecking = false;
clearPrivate(); updateGate(); syncAccount();
location.assign('/logout');
});
window.addEventListener('message', event => {
if (event.origin !== location.origin) return;
if (event.data?.type === 'supra-history-auth-error') {
if (S.authFlow && S.authPopup && event.source === S.authPopup)
accountConnectionFailed('Could not connect to Huggingface. Please sign in again to continue.');
return;
}
if (event.data?.type !== 'supra-history-auth') return;
const silent = event.source === S.silentFrame?.contentWindow;
// Interactive sign-in uses the one-time server handoff. HF's login page
// can sever window.opener; its completion message is only a hint, not proof.
if (!silent) return;
void finishSilentAuth(event.data.authenticated === true, S.accountCheck, event.data.error);
});
window.addEventListener('resize', () => { positionHistory(); updateGate(); });
window.addEventListener('scroll', positionHistory, {passive: true});
async function resume() {
// Opening the entry dialog itself changes focus. Let the initial identity
// and silent-reuse checks finish before considering interactive sign-in.
if (S.authChecking || S.verificationRetryTimer) { void refreshStatus(); return; }
// COOP makes a popup's WindowProxy look closed while OAuth is still active.
// Keep polling the handoff rather than treating that as a cancelled login.
if (!S.authFlow && S.authPopup?.closed) { S.authPopup = null; S.authBusy = false; updateGate(); }
if (S.authFlow) { void refreshStatus(); return; }
if (document.hidden || (!S.accountNeedsCheck && Date.now() - S.lastIdentityCheck < 2000)) return;
if (S.verificationDeferred) {
if (S.verificationRetries < 2) void retryAccountVerification();
return;
}
let verificationEpoch = S.verificationEpoch;
try {
if (S.accountNeedsCheck) {
beginAccountVerification(); verificationEpoch = S.verificationEpoch;
if (!authURL(S.identity?.silent_login_url)) await refreshIdentity();
if (!S.stopped && verificationEpoch === S.verificationEpoch) startSilentAuth();
return;
}
const identity = await refreshIdentity();
if (identity.authenticated) await refreshJobs();
else if (!S.silentFrame) requireEntrySignIn(false);
}
catch (error) {
if (S.stopped || verificationEpoch !== S.verificationEpoch) return;
if (S.verifyRequired) deferAccountVerification();
else if (!quietError(error)) notice('Could not reconnect. Please refresh to retry.');
}
void refreshStatus();
}
document.addEventListener('visibilitychange', () => {
if (document.hidden && !S.authBusy && !S.authFlow) ++S.visibilityEpoch;
clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer);
scheduleSilentTimeout();
if (document.hidden) { shieldPrivateWorkspace(); scheduleJobs(); if (pollStartup()) S.startupTimer = setTimeout(refreshStatus, 20000); }
else void resume();
});
function shieldPrivateWorkspace() {
if (S.authFlow || S.authBusy || S.authChecking || S.stopped) return;
S.accountNeedsCheck = true;
$('st-delete-dialog').close(); show('st-native-notice', false); hideCardTip();
$('st-workspace').dataset.accountChecking = 'true';
$('st-workspace').inert = true;
}
window.addEventListener('focus', () => void resume());
$('st-account-check-retry').addEventListener('click', () => void retryAccountVerification(true));
$('st-account-check-signin').addEventListener('click', () => connectAccount());
window.addEventListener('pagehide', () => {
S.stopped = true; clearTimeout(S.jobsTimer); clearTimeout(S.startupTimer); clearTimeout(S.noticeTimer);
endSilent(); endAccountConnection();
clearTimeout(S.verificationRetryTimer); S.verificationRetryTimer = null; S.verificationDeferred = false;
++S.verificationEpoch; S.identityPromise = null;
S.authChecking = false; S.verifyRequired = false; S.accountCheck = null; S.accountNeedsCheck = true;
S.previewController?.abort(); clearPrivate();
});
window.addEventListener('pageshow', event => { if (event.persisted) { S.stopped = false; S.lastIdentityCheck = 0; boot(); } });
async function boot() {
beginAccountVerification();
const verificationEpoch = S.verificationEpoch;
syncAccount(); updateGate(); void refreshStatus(); void loadExamples();
try {
await refreshIdentity();
if (S.stopped || verificationEpoch !== S.verificationEpoch) return;
startSilentAuth(); renderHistory();
}
catch {
if (!S.stopped && verificationEpoch === S.verificationEpoch)
deferAccountVerification();
}
}
void boot();
})();