Download hf_space/history_ui.js from UniLat3D/Supra-Tex: direct link, hf CLI and curl.
- Browser
- Download file 104 kB
-
https://huggingface.co/spaces/UniLat3D/Supra-Tex/resolve/main/hf_space/history_ui.js
- Command line
-
hf download hf://spaces/UniLat3D/Supra-Tex/hf_space/history_ui.js
-
curl -L -o history_ui.js https://huggingface.co/spaces/UniLat3D/Supra-Tex/resolve/main/hf_space/history_ui.js
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(); | |
| })(); | |