Pixal3D-T / index.html
akhaliq's picture
akhaliq HF Staff
feat: mount /tmp directory and add client-side fallback logic for image previews
c80fdae
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pixal3D | AI Image-to-3D</title>
<!-- Fonts & Icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest"></script>
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.0.0/model-viewer.min.js"></script>
<style>
:root {
--primary: #818cf8;
--primary-dark: #6366f1;
--accent: #10b981;
--bg: #0b0f1a;
--surface: #161c2d;
--surface-light: #222b3e;
--border: rgba(255, 255, 255, 0.08);
--text: #f1f5f9;
--text-dim: #94a3b8;
--glass: rgba(255, 255, 255, 0.03);
--radius-lg: 24px;
--radius-md: 16px;
--radius-sm: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Plus Jakarta Sans', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
background:
radial-gradient(circle at 0% 0%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
radial-gradient(circle at 100% 100%, rgba(16, 185, 129, 0.1) 0%, transparent 40%);
}
/* Top Navigation / Steps */
.app-shell {
display: flex;
height: 100vh;
width: 100vw;
}
.sidebar {
width: 380px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 1.5rem;
overflow-y: auto;
z-index: 10;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
background: rgba(0,0,0,0.2);
}
header {
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border);
background: rgba(11, 15, 26, 0.8);
backdrop-filter: blur(10px);
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
font-family: 'Outfit', sans-serif;
font-weight: 800;
font-size: 1.5rem;
background: linear-gradient(135deg, #fff 0%, #94a3b8 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.logo i {
color: var(--primary);
-webkit-text-fill-color: initial;
}
.steps-nav {
display: flex;
gap: 2rem;
}
.step-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-dim);
transition: all 0.3s;
cursor: pointer;
padding: 0.5rem 0;
border-bottom: 2px solid transparent;
}
.step-item.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.step-item.completed {
color: var(--accent);
}
/* Workspace Panels */
.workspace {
flex: 1;
padding: 2rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.panel {
width: 100%;
height: 100%;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
animation: fadeIn 0.4s ease-out;
}
.panel.active {
display: flex;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* Upload Zone */
.upload-card {
width: 100%;
max-width: 600px;
aspect-ratio: 4/3;
background: var(--surface-light);
border: 2px dashed var(--border);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.upload-card:hover {
border-color: var(--primary);
background: rgba(99, 102, 241, 0.05);
}
.upload-card img {
width: 100%;
height: 100%;
object-fit: contain;
display: none;
}
.upload-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--text-dim);
text-align: center;
padding: 2rem;
}
.upload-hint i {
width: 48px;
height: 48px;
color: var(--primary);
}
/* Result Viewers */
.viewer-wrapper {
width: 100%;
height: 100%;
border-radius: var(--radius-lg);
overflow: hidden;
background: #000;
position: relative;
box-shadow: 0 40px 100px rgba(0,0,0,0.6);
}
#frame-container {
width: 100%;
height: 100%;
position: relative;
}
.preview-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
display: none;
}
.preview-frame.active {
display: block;
}
.viewer-overlay {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background: rgba(11, 15, 26, 0.6);
backdrop-filter: blur(12px);
padding: 1rem 2rem;
border-radius: 100px;
border: 1px solid var(--border);
display: flex;
align-items: center;
gap: 1.5rem;
width: 80%;
max-width: 600px;
}
/* Model Viewer Customization */
model-viewer {
width: 100%;
height: 100%;
background: radial-gradient(circle at 50% 50%, #1a2235 0%, #0b0f1a 100%);
}
/* Sidebar Controls */
.sidebar-section {
margin-bottom: 2rem;
}
.sidebar-section h3 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-dim);
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-wrapper label {
font-size: 0.85rem;
font-weight: 600;
color: #cbd5e1;
display: flex;
justify-content: space-between;
}
.input-wrapper label span {
color: var(--primary);
font-family: monospace;
}
select, input[type="number"] {
background: var(--surface-light);
border: 1px solid var(--border);
color: white;
padding: 0.75rem;
border-radius: var(--radius-sm);
width: 100%;
outline: none;
transition: border-color 0.2s;
}
select:focus {
border-color: var(--primary);
}
input[type="range"] {
-webkit-appearance: none;
height: 4px;
background: var(--border);
border-radius: 2px;
margin: 10px 0;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
background: var(--primary);
border-radius: 50%;
cursor: pointer;
border: 3px solid var(--surface);
box-shadow: 0 0 10px rgba(129, 140, 248, 0.4);
}
/* Action Buttons */
.btn-stack {
margin-top: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.btn {
width: 100%;
padding: 1rem;
border-radius: var(--radius-md);
font-weight: 700;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
border: none;
}
.btn-primary {
background: var(--primary);
color: white;
box-shadow: 0 10px 20px rgba(99, 102, 241, 0.2);
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
}
.btn-primary:disabled {
background: #334155;
color: #64748b;
cursor: not-allowed;
transform: none;
}
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover {
background: var(--border);
}
/* Mode Buttons */
.mode-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.mode-tab {
background: var(--surface-light);
border: 1px solid var(--border);
padding: 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: 600;
text-align: center;
cursor: pointer;
transition: all 0.2s;
color: var(--text-dim);
}
.mode-tab.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* Examples Footer */
.examples-drawer {
padding: 1.5rem 2rem;
border-top: 1px solid var(--border);
background: var(--surface);
}
.examples-grid {
display: flex;
gap: 1rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.example-item {
flex: 0 0 100px;
aspect-ratio: 1/1;
border-radius: var(--radius-md);
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.example-item:hover {
transform: translateY(-4px);
border-color: var(--primary);
}
.example-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Loading & Status */
.loading-overlay {
position: fixed;
inset: 0;
background: rgba(11, 15, 26, 0.9);
z-index: 1000;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
backdrop-filter: blur(8px);
}
.loader-ring {
width: 80px;
height: 80px;
border-radius: 50%;
border: 4px solid var(--border);
border-top-color: var(--primary);
animation: spin 1s linear infinite;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
.status-toast {
position: fixed;
bottom: 2rem;
right: 2rem;
background: var(--surface-light);
padding: 1rem 1.5rem;
border-radius: var(--radius-md);
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
display: none;
z-index: 2000;
animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
</style>
</head>
<body>
<div class="app-shell">
<!-- Left Sidebar: Controls -->
<div class="sidebar">
<div class="logo" style="margin-bottom: 2.5rem;">
<i data-lucide="sparkles"></i>
<span>Pixal3D</span>
</div>
<div class="sidebar-section">
<h3><i data-lucide="sliders-horizontal" style="width: 14px;"></i> Base Settings</h3>
<div class="control-group">
<div class="input-wrapper">
<label>Target Resolution</label>
<select id="resolution">
<option value="1024">1024 (Balanced)</option>
<option value="1536" selected>1536 (High Quality)</option>
</select>
</div>
<div class="input-wrapper">
<label>Generation Seed <span>#<span id="seed-display">42</span></span></label>
<div style="display: flex; gap: 0.5rem;">
<input type="number" id="seed" value="42" style="flex: 1;">
<button class="btn btn-outline" style="width: 50px; padding: 0;" onclick="randomizeSeed()">
<i data-lucide="rotate-cw" style="width: 16px;"></i>
</button>
</div>
</div>
</div>
</div>
<div class="sidebar-section" id="render-controls" style="display: none;">
<h3><i data-lucide="palette" style="width: 14px;"></i> Render Mode</h3>
<div class="mode-grid" id="mode-grid">
<!-- Tabs injected via JS -->
</div>
</div>
<div class="sidebar-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; cursor: pointer;" onclick="toggleAdvanced()">
<h3 style="margin-bottom: 0;"><i data-lucide="shield-alert" style="width: 14px;"></i> Advanced Engine</h3>
<i data-lucide="chevron-down" id="adv-chevron" style="width: 16px; transition: transform 0.3s;"></i>
</div>
<div id="advanced-settings" style="display: none; padding-top: 1rem; border-top: 1px solid var(--border);">
<div class="control-group">
<div class="input-wrapper">
<label>SS Guidance <span><span id="ss_gs_val">7.5</span></span></label>
<input type="range" id="ss_gs" min="1" max="10" step="0.1" value="7.5" oninput="updateVal('ss_gs')">
</div>
<div class="input-wrapper">
<label>SS Sampling <span><span id="ss_steps_val">12</span></span></label>
<input type="range" id="ss_steps" min="1" max="50" step="1" value="12" oninput="updateVal('ss_steps')">
</div>
<div class="input-wrapper">
<label>Shape Guidance <span><span id="shape_gs_val">7.5</span></span></label>
<input type="range" id="shape_gs" min="1" max="10" step="0.1" value="7.5" oninput="updateVal('shape_gs')">
</div>
<hr style="border: 0; border-top: 1px solid var(--border); margin: 0.5rem 0;">
<div class="input-wrapper">
<label>Decimation <span><span id="decim_val">1M</span></span></label>
<input type="range" id="decimation" min="100000" max="1000000" step="10000" value="1000000" oninput="updateVal('decimation')">
</div>
</div>
</div>
</div>
<div class="btn-stack">
<button class="btn btn-primary" id="generate-btn" disabled>
<i data-lucide="zap"></i>
Start Generation
</button>
<button class="btn btn-outline" id="extract-btn" style="display: none;">
<i data-lucide="box"></i>
Extract Mesh (GLB)
</button>
<button class="btn btn-outline" id="download-btn" style="display: none; background: rgba(16, 185, 129, 0.1); border-color: var(--accent); color: var(--accent);">
<i data-lucide="download"></i>
Download Asset
</button>
</div>
</div>
<!-- Right: Main Area -->
<div class="main-content">
<header>
<div class="steps-nav">
<div class="step-item active" id="step-1">
<i data-lucide="image"></i>
<span>1. SOURCE</span>
</div>
<div class="step-item" id="step-2">
<i data-lucide="view"></i>
<span>2. PREVIEW</span>
</div>
<div class="step-item" id="step-3">
<i data-lucide="box"></i>
<span>3. RESULT</span>
</div>
</div>
<div style="color: var(--text-dim); font-size: 0.8rem; font-weight: 500;">
TRELLIS.2 Engine • V2.6
</div>
</header>
<div class="workspace">
<!-- Panel 1: Upload -->
<div class="panel active" id="panel-1">
<div class="upload-card" id="drop-zone" onclick="document.getElementById('file-input').click()">
<input type="file" id="file-input" hidden accept="image/*">
<div class="upload-hint" id="upload-hint">
<i data-lucide="cloud-upload"></i>
<h2 style="font-family: 'Outfit'; margin-top: 1rem;">Upload Reference</h2>
<p>Drag and drop any image, or click to browse</p>
</div>
<img id="source-preview" src="" alt="Source">
</div>
</div>
<!-- Panel 2: Multi-frame Preview -->
<div class="panel" id="panel-2">
<div class="viewer-wrapper">
<div id="frame-container">
<!-- Injected via JS -->
</div>
<div class="viewer-overlay">
<i data-lucide="move-horizontal" style="color: var(--primary); width: 20px;"></i>
<input type="range" id="angle-slider" min="0" max="7" value="0" step="1" style="flex: 1;">
<div style="font-family: monospace; font-weight: 700; color: var(--primary); font-size: 0.8rem;">
VIEW_ANGLE: <span id="angle-display">00</span>°
</div>
</div>
</div>
</div>
<!-- Panel 3: 3D Result -->
<div class="panel" id="panel-3">
<div class="viewer-wrapper">
<model-viewer id="main-3d-viewer"
camera-controls
auto-rotate
shadow-intensity="1.5"
environment-image="neutral"
exposure="1.2">
<div slot="progress-bar" style="background: var(--primary); height: 4px;"></div>
</model-viewer>
</div>
</div>
</div>
<!-- Footer: Examples -->
<div class="examples-drawer">
<h4 style="font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem;">Sample Gallery</h4>
<div class="examples-grid" id="examples-grid">
<!-- Injected via JS -->
</div>
</div>
</div>
</div>
<div class="loading-overlay" id="loading-overlay">
<div class="loader-ring"></div>
<div style="text-align: center;">
<h2 id="loading-title" style="font-family: 'Outfit'; margin-bottom: 0.5rem;">Synthesizing Geometry</h2>
<p id="loading-subtitle" style="color: var(--text-dim);">The neural engine is crafting your 3D model...</p>
</div>
</div>
<div class="status-toast" id="toast">Generation started!</div>
<script type="module">
import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
let client;
let currentFile = null;
let generationResult = null;
let currentMode = "shaded_forest";
let currentFrame = 0;
let currentStep = 1;
const MODES = [
{ name: "Normal", key: "normal" },
{ name: "Clay", key: "clay" },
{ name: "Color", key: "base_color" },
{ name: "Forest", key: "shaded_forest" },
{ name: "Sunset", key: "shaded_sunset" },
{ name: "Blue", key: "shaded_courtyard" }
];
async function init() {
lucide.createIcons();
try {
client = await Client.connect(window.location.origin);
setupUI();
loadSamples();
} catch (err) {
console.error("Connection error:", err);
showToast("Connection failed. Try refreshing.");
}
}
function setupUI() {
// File Handling
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
dropZone.ondragover = (e) => { e.preventDefault(); dropZone.style.borderColor = 'var(--primary)'; };
dropZone.ondragleave = () => dropZone.style.borderColor = 'var(--border)';
dropZone.ondrop = (e) => {
e.preventDefault();
if (e.dataTransfer.files.length) handleImageUpload(e.dataTransfer.files[0]);
};
fileInput.onchange = (e) => { if (e.target.files.length) handleImageUpload(e.target.files[0]); };
// Buttons
document.getElementById('generate-btn').onclick = startGeneration;
document.getElementById('extract-btn').onclick = startExtraction;
document.getElementById('download-btn').onclick = () => {
const link = document.createElement('a');
link.href = document.getElementById('main-3d-viewer').src;
link.download = "pixal3d_export.glb";
link.click();
};
// Slider
document.getElementById('angle-slider').oninput = (e) => {
currentFrame = parseInt(e.target.value);
document.getElementById('angle-display').textContent = (currentFrame * 22.5).toFixed(0).padStart(2, '0');
updateFrame();
};
// Mode Grid
const grid = document.getElementById('mode-grid');
MODES.forEach(m => {
const tab = document.createElement('div');
tab.className = `mode-tab ${m.key === currentMode ? 'active' : ''}`;
tab.textContent = m.name;
tab.onclick = () => {
currentMode = m.key;
document.querySelectorAll('.mode-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
updateFrame();
};
grid.appendChild(tab);
});
}
async function handleImageUpload(file) {
currentFile = file;
const reader = new FileReader();
reader.onload = (e) => {
const img = document.getElementById('source-preview');
const hint = document.getElementById('upload-hint');
img.src = e.target.result;
img.style.display = 'block';
hint.style.display = 'none';
document.getElementById('generate-btn').disabled = false;
setStep(1);
};
reader.readAsDataURL(file);
// Background pre-warm
client.predict("/preprocess", { image: handle_file(file) }).catch(console.error);
}
function setStep(num) {
currentStep = num;
document.querySelectorAll('.step-item').forEach((item, i) => {
item.className = 'step-item';
if (i + 1 < num) item.classList.add('completed');
if (i + 1 === num) item.classList.add('active');
});
document.querySelectorAll('.panel').forEach((p, i) => {
p.classList.toggle('active', i + 1 === num);
});
// Toggle side controls based on step
document.getElementById('render-controls').style.display = (num >= 2) ? 'block' : 'none';
document.getElementById('extract-btn').style.display = (num === 2) ? 'flex' : 'none';
document.getElementById('download-btn').style.display = (num === 3) ? 'flex' : 'none';
}
async function startGeneration() {
if (!currentFile) return;
showLoading("Neural Synthesis", "Optimizing geometry for " + (document.getElementById('resolution').value) + "px output...");
try {
const params = {
image: handle_file(currentFile),
seed: parseInt(document.getElementById('seed').value),
resolution: parseInt(document.getElementById('resolution').value),
ss_guidance_strength: parseFloat(document.getElementById('ss_gs').value),
ss_sampling_steps: parseInt(document.getElementById('ss_steps').value),
shape_slat_guidance_strength: parseFloat(document.getElementById('shape_gs').value)
};
const result = await client.predict("/generate_3d", params);
generationResult = result.data[0];
populateFrames(generationResult.render_paths);
setStep(2);
hideLoading();
showToast("Generation complete!");
} catch (err) {
console.error(err);
hideLoading();
showToast("An error occurred during synthesis.");
}
}
function populateFrames(renderPaths) {
const container = document.getElementById('frame-container');
container.innerHTML = '';
Object.entries(renderPaths).forEach(([mode, files]) => {
files.forEach((file, i) => {
const img = document.createElement('img');
// Try the URL from Gradio, fallback to our mounted /tmp route if it's an absolute local path
let url = file.url;
if (!url && file.path) {
const filename = file.path.split(/[\\/]/).pop();
url = `/tmp/${filename}`;
}
img.src = url;
img.className = 'preview-frame';
img.id = `frame-${mode}-${i}`;
img.onerror = () => {
// Fallback attempt if the first URL fails
const filename = file.path ? file.path.split(/[\\/]/).pop() : null;
if (filename && !img.src.includes('/tmp/')) {
img.src = `/tmp/${filename}`;
}
};
container.appendChild(img);
});
});
updateFrame();
}
function updateFrame() {
document.querySelectorAll('.preview-frame').forEach(f => f.classList.remove('active'));
const active = document.getElementById(`frame-${currentMode}-${currentFrame}`);
if (active) active.classList.add('active');
}
async function startExtraction() {
if (!generationResult) return;
showLoading("Finalizing Mesh", "Performing PBR texture baking and decimation...");
try {
const params = {
state_path: generationResult.state_path,
decimation_target: parseInt(document.getElementById('decimation').value),
texture_size: 4096 // Constant for highest quality
};
const result = await client.predict("/extract_glb_api", params);
const glbUrl = result.data[0].url;
const viewer = document.getElementById('main-3d-viewer');
viewer.src = glbUrl;
setStep(3);
hideLoading();
showToast("3D Asset ready!");
} catch (err) {
console.error(err);
hideLoading();
showToast("Extraction failed.");
}
}
function loadSamples() {
const grid = document.getElementById('examples-grid');
const samples = [
'assets/example_image/0a34fae7ba57cb8870df5325b9c30ea474def1b0913c19c596655b85a79fdee4.webp',
'assets/example_image/0e4984a9b3765ce80e9853443f9319ecedf90885c74b56cccfebc09402740f8a.webp',
'assets/example_image/130c2b18f1651a70f8aa15b2c99f8dba29bb943044d92871f9223bd3e989e8b1.webp',
'assets/example_image/22a868bac8e62511fccd2bc82ed31ae77ed31ae2a8a149be7150957f11b30c9b.webp',
'assets/example_image/3903b87907a6b4947006e6fc7c0c64f40cd98932a02bf0ecf7d6dfae776f3a38.webp',
'assets/example_image/4bc7abe209c8673dd3766ee4fad14d40acbed02d118e7629f645c60fd77313f1.webp'
];
samples.forEach(path => {
const div = document.createElement('div');
div.className = 'example-item';
div.innerHTML = `<img src="${path}">`;
div.onclick = async () => {
showLoading("Fetching Sample", "Loading high-resolution asset from gallery...");
const res = await fetch(path);
const blob = await res.blob();
const file = new File([blob], "sample.webp", { type: "image/webp" });
await handleImageUpload(file);
hideLoading();
};
grid.appendChild(div);
});
}
// Helpers
window.toggleAdvanced = () => {
const el = document.getElementById('advanced-settings');
const chev = document.getElementById('adv-chevron');
const isOpen = el.style.display === 'block';
el.style.display = isOpen ? 'none' : 'block';
chev.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(180deg)';
};
window.updateVal = (id) => {
const val = document.getElementById(id).value;
let label = val;
if (id === 'decimation') label = (val/1000000).toFixed(1) + 'M';
document.getElementById(id + '_val').textContent = label;
};
window.randomizeSeed = () => {
const s = Math.floor(Math.random() * 999999);
document.getElementById('seed').value = s;
document.getElementById('seed-display').textContent = s;
};
function showLoading(title, sub) {
document.getElementById('loading-title').textContent = title;
document.getElementById('loading-subtitle').textContent = sub;
document.getElementById('loading-overlay').style.display = 'flex';
}
function hideLoading() {
document.getElementById('loading-overlay').style.display = 'none';
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.style.display = 'block';
setTimeout(() => t.style.display = 'none', 3000);
}
init();
</script>
</body>
</html>