Pixal3D / index.html
Yang2001's picture
feat: support manual FOV input (deg/rad) with auto MoGe-2 fallback
06f810c
Raw History Blame Contribute Delete
63.9 kB
<!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; min-width: 0; 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; } .queue-badge { display: flex; align-items: center; gap: 5px; background: rgba(99, 102, 241, 0.15); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 20px; padding: 5px 12px; font-size: 0.75rem; font-weight: 600; color: #a5b4fc; -webkit-text-fill-color: initial; margin-left: auto; white-space: nowrap; transition: all 0.3s ease; } .queue-badge.busy { background: rgba(251, 146, 60, 0.2); border-color: rgba(251, 146, 60, 0.5); color: #fb923c; animation: badge-pulse 2s infinite; } @keyframes badge-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } .queue-badge i { color: inherit; } .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; position: relative; } .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); overflow: hidden; position: relative; } .examples-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } .examples-grid { display: flex; flex-wrap: nowrap; gap: 1rem; overflow-x: auto; overflow-y: hidden; padding-bottom: 1rem; scrollbar-width: none; -ms-overflow-style: none; } .examples-grid::-webkit-scrollbar { display: none; } .examples-track { display: flex; gap: 1rem; } .example-item { flex: 0 0 140px; min-width: 140px; 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); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25); } /* Gallery Slider */ .gallery-slider-wrap { margin-top: 0.75rem; padding: 0 0.25rem; } .gallery-slider { -webkit-appearance: none; width: 100%; height: 6px; background: var(--border); border-radius: 3px; outline: none; cursor: pointer; transition: background 0.2s; } .gallery-slider:hover { background: rgba(255, 255, 255, 0.12); } .gallery-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: linear-gradient(135deg, var(--primary), var(--accent)); border-radius: 50%; cursor: grab; box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4); transition: transform 0.2s, box-shadow 0.2s; } .gallery-slider::-webkit-slider-thumb:hover { transform: scale(1.2); box-shadow: 0 4px 16px rgba(99, 102, 241, 0.6); } .gallery-slider::-webkit-slider-thumb:active { cursor: grabbing; transform: scale(1.1); } .gallery-slider::-moz-range-thumb { width: 20px; height: 20px; background: linear-gradient(135deg, var(--primary), var(--accent)); border-radius: 50%; border: none; cursor: grab; box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4); } .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); } /* Reference thumbnail in preview/result panels */ .ref-thumbnail { position: absolute; top: 1rem; right: 1rem; width: 400px; height: 400px; object-fit: cover; border-radius: var(--radius-sm); border: 2px solid var(--border); z-index: 20; display: none; box-shadow: 0 4px 12px rgba(0,0,0,0.4); cursor: pointer; transition: transform 0.2s; } .ref-thumbnail:hover { transform: scale(1.1); } /* Lightbox */ .lightbox-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.85); z-index: 3000; display: none; align-items: center; justify-content: center; cursor: zoom-out; backdrop-filter: blur(6px); } .lightbox-overlay img { max-width: 80%; max-height: 80%; border-radius: var(--radius-md); box-shadow: 0 20px 60px rgba(0,0,0,0.6); } </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 id="queue-badge" class="queue-badge" title="Current queue size"> <i data-lucide="users" style="width: 13px; height: 13px;"></i> <span>Queue: <span id="queue-count">0</span></span> </div> </div> <div class="sidebar-section" style="margin-bottom: 1.5rem;"> <p style="font-size: 0.82rem; color: var(--text-dim); line-height: 1.6;"> 1. Upload an image and click Generate.<br> 2. Click Extract GLB to export.<br> 3. Download the generated GLB file. </p> <p style="font-size: 0.72rem; color: var(--text-dim); line-height: 1.5; margin-top: 0.5rem; opacity: 0.7;"> Note: FOV is auto-estimated via MoGe-2.<br> If distortion occurs, try manual FOV.<br> Default <b style="color: var(--primary);">0.2 rad</b> generally works well. </p> <a href="https://ldyang694.github.io/projects/pixal3d/" target="_blank" class="btn btn-outline" style="margin-top: 1rem; padding: 0.6rem 1rem; font-size: 0.85rem;"> <i data-lucide="globe" style="width: 16px;"></i> Project Page </a> </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 class="input-wrapper"> <label> Camera FOV <span style="display: flex; align-items: center; gap: 0.35rem;"> <input type="checkbox" id="fov-auto" checked style="accent-color: var(--primary); width: 14px; height: 14px; cursor: pointer;"> <span style="font-size: 0.75rem; color: var(--text-dim); cursor: pointer;" onclick="document.getElementById('fov-auto').click()">Auto</span> </span> </label> <div style="display: flex; gap: 0.5rem;"> <input type="number" id="manual-fov" value="0.2" min="0.0175" max="2.9671" step="0.01" disabled style="opacity: 0.4; flex: 1;"> <select id="fov-unit" disabled style="opacity: 0.4; width: 70px; padding: 0.5rem;"> <option value="deg">deg</option> <option value="rad" selected>rad</option> </select> </div> <p id="fov-hint" style="font-size: 0.65rem; color: var(--text-dim); margin-top: 0.15rem;"> Manual FOV in radians (0.02–2.97 rad) </p> </div> </div> </div> <div class="sidebar-section" id="render-controls" style="display: none;"> <h3><i data-lucide="palette" style="width: 14px;"></i> Render Mode For Preview</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> <button class="btn btn-outline" id="clear-btn" title="Clear all & restart" style="width: 34px; height: 34px; padding: 0; border-radius: 50%; display: flex; align-items: center; justify-content: center; border-color: rgba(248,113,113,0.3);"> <i data-lucide="trash-2" style="width: 16px; height: 16px; color: #f87171;"></i> </button> </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"> <img id="ref-thumb-2" class="ref-thumbnail" src="" alt="Reference"> <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 id="camera-info" style="font-family: monospace; font-size: 0.75rem; color: var(--text-dim); display: none; border-left: 1px solid var(--border); padding-left: 1rem;"> <span>FOV: <span id="fov-display" style="color: var(--accent);">--</span> rad</span> <span style="margin-left: 0.75rem;">Dist: <span id="dist-display" style="color: var(--accent);">--</span></span> </div> </div> </div> </div> <!-- Panel 3: 3D Result --> <div class="panel" id="panel-3"> <img id="ref-thumb-3" class="ref-thumbnail" src="" alt="Reference"> <div class="viewer-wrapper"> <model-viewer id="main-3d-viewer" camera-controls auto-rotate camera-orbit="-180deg 90deg auto" 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"> <div class="examples-header"> <h4 style="font-size: 0.75rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.1em;">Sample Gallery</h4> </div> <div class="examples-grid" id="examples-grid"> <!-- Injected via JS --> </div> <div class="gallery-slider-wrap"> <input type="range" id="gallery-slider" class="gallery-slider" min="0" max="100" value="0"> </div> </div> </div> </div> <div class="loading-overlay" id="loading-overlay"> <div class="loader-ring"></div> <div style="text-align: center; width: 100%; max-width: 500px; padding: 0 2rem;"> <!-- Progress stages --> <div id="progress-stages" style="display: none; text-align: left;"> <div class="progress-stage" id="progress-stage-item" style="margin-bottom: 1rem;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem;"> <span id="progress-stage-name" style="font-size: 0.85rem; font-weight: 600; color: var(--primary);">Initializing...</span> <span id="progress-step-text" style="font-size: 0.75rem; color: var(--text-dim); font-family: monospace;">0/0</span> </div> <div style="width: 100%; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden;"> <div id="progress-bar-fill" style="width: 0%; height: 100%; background: linear-gradient(90deg, var(--primary), var(--accent)); border-radius: 3px; transition: width 0.3s ease;"></div> </div> </div> <!-- Stage history log --> <div id="progress-log" style="font-size: 0.75rem; color: var(--text-dim); line-height: 1.8; max-height: 180px; overflow-y: auto; margin-top: 1rem; padding-top: 0.5rem; border-top: 1px solid var(--border);"></div> </div> </div> </div> <div class="lightbox-overlay" id="lightbox-overlay" onclick="closeLightbox()"> <img id="lightbox-img" src="" alt="Enlarged"> </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 preprocessedFile = null; let isPreprocessing = false; let generationResult = null; let currentMode = "shaded_forest"; let currentFrame = 0; const sessionId = crypto.randomUUID(); 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" } ]; // Global queue status polling function startQueuePolling() { async function pollQueue() { try { const resp = await fetch(`/queue?session_id=${sessionId}`); if (resp.ok) { const data = await resp.json(); const badge = document.getElementById('queue-badge'); const countEl = document.getElementById('queue-count'); const total = data.total_waiting + (data.gpu_busy ? 1 : 0); countEl.textContent = total; if (total > 0) { badge.classList.add('busy'); } else { badge.classList.remove('busy'); } } } catch (e) { /* ignore */ } } pollQueue(); setInterval(pollQueue, 3000); } async function init() { lucide.createIcons(); startQueuePolling(); 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(); }; // Clear button document.getElementById('clear-btn').onclick = () => { // Reset state currentFile = null; generationResult = null; currentFrame = 0; currentMode = "shaded_forest"; // Reset source preview document.getElementById('source-preview').src = ''; document.getElementById('source-preview').style.display = 'none'; document.getElementById('upload-hint').style.display = 'flex'; document.getElementById('file-input').value = ''; // Reset generate button document.getElementById('generate-btn').disabled = true; // Reset preview frames document.getElementById('frame-container').innerHTML = ''; document.getElementById('angle-slider').value = 0; document.getElementById('angle-display').textContent = '00'; // Reset 3D viewer resetModelViewer(); // Reset thumbnails document.getElementById('ref-thumb-2').style.display = 'none'; document.getElementById('ref-thumb-2').src = ''; document.getElementById('ref-thumb-3').style.display = 'none'; document.getElementById('ref-thumb-3').src = ''; // Reset mode tabs document.querySelectorAll('.mode-tab').forEach(t => { t.classList.toggle('active', t.textContent === 'Forest'); }); // Go back to step 1 setStep(1); showToast("Cleared. Ready for new upload."); }; // Step navigation click document.getElementById('step-1').onclick = () => setStep(1); document.getElementById('step-2').onclick = () => { if (generationResult) setStep(2); }; document.getElementById('step-3').onclick = () => { if (document.getElementById('main-3d-viewer').src) setStep(3); }; // 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(); }; // FOV auto toggle & unit switch const fovAutoCheck = document.getElementById('fov-auto'); const fovInput = document.getElementById('manual-fov'); const fovUnit = document.getElementById('fov-unit'); const fovHint = document.getElementById('fov-hint'); function updateFovEnabled() { const manual = !fovAutoCheck.checked; fovInput.disabled = !manual; fovUnit.disabled = !manual; fovInput.style.opacity = manual ? '1' : '0.4'; fovUnit.style.opacity = manual ? '1' : '0.4'; } fovAutoCheck.onchange = updateFovEnabled; fovUnit.onchange = () => { const val = parseFloat(fovInput.value); if (isNaN(val)) return; if (fovUnit.value === 'rad') { // deg -> rad fovInput.value = (val * Math.PI / 180).toFixed(4); fovInput.min = '0.0175'; fovInput.max = '2.9671'; fovInput.step = '0.01'; fovHint.textContent = 'Manual FOV in radians (0.02–2.97 rad)'; } else { // rad -> deg fovInput.value = (val * 180 / Math.PI).toFixed(1); fovInput.min = '1'; fovInput.max = '170'; fovInput.step = '0.5'; fovHint.textContent = 'Uncheck "Auto" to manually set FOV (1°–170°)'; } }; // 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; preprocessedFile = null; isPreprocessing = true; document.getElementById('generate-btn').disabled = true; 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'; setStep(1); }; reader.readAsDataURL(file); // Call preprocess and update with segmented result try { const result = await client.predict("/preprocess", { image: handle_file(file) }); const processedUrl = result.data[0].url; if (processedUrl) { preprocessedFile = processedUrl; document.getElementById('source-preview').src = processedUrl; document.getElementById('ref-thumb-2').src = processedUrl; document.getElementById('ref-thumb-2').style.display = 'block'; document.getElementById('ref-thumb-3').src = processedUrl; document.getElementById('ref-thumb-3').style.display = 'block'; } } catch (err) { console.error("Preprocess failed:", err); } isPreprocessing = false; document.getElementById('generate-btn').disabled = false; } 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; // Clear old preview frames and 3D result generationResult = null; document.getElementById('frame-container').innerHTML = ''; document.getElementById('angle-slider').value = 0; document.getElementById('angle-display').textContent = '00'; resetModelViewer(); document.getElementById('extract-btn').style.display = 'none'; document.getElementById('download-btn').style.display = 'none'; // Stay on step 1 during generation setStep(1); showLoading(); startProgressListener(); try { // Register in queue immediately (before Gradio dispatch delay) await fetch(`/queue/join?session_id=${sessionId}`); const params = { image: preprocessedFile ? handle_file(preprocessedFile) : 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), manual_fov: (() => { if (document.getElementById('fov-auto').checked) return -1.0; const v = parseFloat(document.getElementById('manual-fov').value); return document.getElementById('fov-unit').value === 'rad' ? v * 180 / Math.PI : v; })(), session_id: sessionId }; const result = await client.predict("/generate_3d", params); generationResult = result.data[0]; stopProgressListener(); populateFrames(generationResult.render_paths); // Display camera info if (generationResult.camera_angle_x != null) { document.getElementById('fov-display').textContent = generationResult.camera_angle_x.toFixed(3); document.getElementById('dist-display').textContent = generationResult.distance.toFixed(3); document.getElementById('camera-info').style.display = 'inline'; } setStep(2); hideLoading(); showToast("Generation complete!"); } catch (err) { console.error(err); stopProgressListener(); hideLoading(); showToast("An error occurred during synthesis."); } } // Progress Polling let progressInterval = null; let lastStageName = ""; function startProgressListener() { // Show progress UI document.getElementById('progress-stages').style.display = 'block'; document.getElementById('progress-log').innerHTML = ''; document.getElementById('progress-stage-name').textContent = 'Initializing...'; document.getElementById('progress-step-text').textContent = ''; document.getElementById('progress-bar-fill').style.width = '0%'; lastStageName = ""; // Poll every 500ms: check queue status + progress let queuePollCount = 0; progressInterval = setInterval(async () => { queuePollCount++; try { // Check queue status first const qResp = await fetch(`/queue?session_id=${sessionId}`); if (qResp.ok) { const q = await qResp.json(); if (q.position > 0) { // We are registered and waiting, position = number of tasks ahead document.getElementById('progress-stage-name').textContent = `In queue: ${q.position} request${q.position > 1 ? 's' : ''} ahead`; document.getElementById('progress-step-text').textContent = 'Waiting for GPU...'; document.getElementById('progress-bar-fill').style.width = '0%'; return; } if (q.position === -2) { // Registered, GPU is free, about to start document.getElementById('progress-stage-name').textContent = 'Connecting to GPU...'; document.getElementById('progress-step-text').textContent = ''; document.getElementById('progress-bar-fill').style.width = '0%'; return; } if (q.position === -1) { // Not registered yet (shouldn't happen often since we call /queue/join first) if (q.total_ahead_for_unregistered > 0) { const ahead = q.total_ahead_for_unregistered; document.getElementById('progress-stage-name').textContent = `In queue: ${ahead} request${ahead > 1 ? 's' : ''} ahead`; document.getElementById('progress-step-text').textContent = 'Waiting for GPU...'; } else { document.getElementById('progress-stage-name').textContent = 'Connecting to GPU...'; document.getElementById('progress-step-text').textContent = ''; } document.getElementById('progress-bar-fill').style.width = '0%'; return; } // position == 0 means we are currently running } const resp = await fetch(`/progress?session_id=${sessionId}`); if (!resp.ok) return; const data = await resp.json(); if (data.done) { stopProgressListener(); return; } // Don't show bare "Waiting..." from empty progress file if (data.stage === "Waiting..." && data.step === 0 && data.total === 0) { document.getElementById('progress-stage-name').textContent = 'Preparing inference...'; document.getElementById('progress-step-text').textContent = ''; document.getElementById('progress-bar-fill').style.width = '0%'; return; } updateProgressUI(data); } catch (e) {} }, 500); } function stopProgressListener() { if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } } function updateProgressUI(data) { const stageName = data.stage || ''; const step = data.step || 0; const total = data.total || 0; // If stage changed, log the previous one as completed if (stageName && stageName !== lastStageName) { if (lastStageName) { const logEl = document.getElementById('progress-log'); logEl.innerHTML += `<div style="display:flex;align-items:center;gap:0.4rem;"><span style="color:var(--accent);">✓</span> ${lastStageName}</div>`; logEl.scrollTop = logEl.scrollHeight; } lastStageName = stageName; } // Update current stage display document.getElementById('progress-stage-name').textContent = stageName; if (total > 0) { document.getElementById('progress-step-text').textContent = `${step}/${total}`; const pct = Math.min(100, (step / total) * 100); document.getElementById('progress-bar-fill').style.width = pct + '%'; } else { document.getElementById('progress-step-text').textContent = ''; document.getElementById('progress-bar-fill').style.width = '0%'; } } 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'); } // Destroy and recreate model-viewer to fully purge old mesh from WebGL function resetModelViewer() { const container = document.querySelector('#panel-3 .viewer-wrapper'); const old = document.getElementById('main-3d-viewer'); if (old) old.remove(); const mv = document.createElement('model-viewer'); mv.id = 'main-3d-viewer'; mv.setAttribute('camera-controls', ''); mv.setAttribute('auto-rotate', ''); mv.setAttribute('camera-orbit', '-180deg 90deg auto'); mv.setAttribute('shadow-intensity', '1.5'); mv.setAttribute('environment-image', 'neutral'); mv.setAttribute('exposure', '1.2'); mv.style.width = '100%'; mv.style.height = '100%'; mv.style.background = 'radial-gradient(circle at 50% 50%, #1a2235 0%, #0b0f1a 100%)'; mv.style.visibility = 'hidden'; mv.innerHTML = '<div slot="progress-bar" style="background: var(--primary); height: 4px;"></div>'; container.appendChild(mv); return mv; } async function startExtraction() { if (!generationResult) return; // Switch away from panel-3 immediately so user won't see stale mesh if (currentStep === 3) setStep(2); // Destroy old model-viewer and create a fresh one (purges WebGL scene completely) const viewer = resetModelViewer(); showLoading(); startProgressListener(); try { // Register in queue immediately (before Gradio dispatch delay) await fetch(`/queue/join?session_id=${sessionId}`); const params = { state_path: generationResult.state_path, decimation_target: parseInt(document.getElementById('decimation').value), texture_size: 4096, session_id: sessionId }; const result = await client.predict("/extract_glb_api", params); const glbUrl = result.data[0].url; stopProgressListener(); // Wait for the new model to fully load before revealing (with timeout fallback) await new Promise((resolve) => { let settled = false; const onLoad = () => { if (settled) return; settled = true; viewer.removeEventListener('load', onLoad); clearTimeout(timer); resolve(); }; // Timeout fallback: if load event never fires (e.g. model-viewer error), // resolve anyway after 30s to avoid permanent hang const timer = setTimeout(() => { if (settled) return; settled = true; viewer.removeEventListener('load', onLoad); resolve(); }, 30000); viewer.addEventListener('load', onLoad); viewer.src = glbUrl; }); viewer.style.visibility = 'visible'; setStep(3); hideLoading(); showToast("3D Asset ready!"); } catch (err) { console.error(err); stopProgressListener(); viewer.style.visibility = 'visible'; hideLoading(); showToast("Extraction failed."); } } function loadSamples() { const grid = document.getElementById('examples-grid'); const samples = [ 'assets/images/0_img.png', 'assets/images/1_img.png', 'assets/images/3_img.webp', 'assets/images/4_img.png', 'assets/images/5_img.webp', 'assets/images/6_img.png', 'assets/images/7_img.png', 'assets/images/9_img.png', 'assets/images/10_img.webp', 'assets/images/11_img.png', 'assets/images/12_img.png', 'assets/images/17_img.png', 'assets/images/21_img.png', 'assets/images/s_13_img.jpg', 'assets/images/s_14_img.jpg', 'assets/images/s_15_img.png', 'assets/images/s_16_img.png', 'assets/images/s_18_img.png', 'assets/images/s_20_img.webp', 'assets/images/musicman.png', 'assets/images/pizza.png', 'assets/images/sculpt.png', 'assets/images/treehouse.png', 'assets/images/warship.png', 'assets/images/5c80e5e03a3b60b6f03eaf555ba1dafc0e4230c472d7e8c8e2c5ca0a0dfcef10.webp', 'assets/images/c9340e744541f310bf89838f652602961d3e5950b31cd349bcbfc7e59e15cd2e.webp', 'assets/images/f94e2b76494ce2cf1874611273e5fb3d76b395793bb5647492fa85c2ce0a248b.webp' ]; // Create track element const track = document.createElement('div'); track.className = 'examples-track'; function createItem(path) { const div = document.createElement('div'); div.className = 'example-item'; div.innerHTML = `<img src="${path}" draggable="false">`; div.onclick = async () => { showLoading(); const res = await fetch(path); const blob = await res.blob(); const ext = path.split('.').pop().toLowerCase(); const mimeMap = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp' }; const mime = mimeMap[ext] || 'image/png'; const file = new File([blob], path.split('/').pop(), { type: mime }); await handleImageUpload(file); hideLoading(); }; return div; } samples.forEach(path => track.appendChild(createItem(path))); samples.forEach(path => track.appendChild(createItem(path))); grid.appendChild(track); } // Helpers window.openLightbox = (src) => { const lb = document.getElementById('lightbox-overlay'); document.getElementById('lightbox-img').src = src; lb.style.display = 'flex'; }; window.closeLightbox = () => { document.getElementById('lightbox-overlay').style.display = 'none'; }; // Thumbnail click to enlarge document.getElementById('ref-thumb-2').onclick = (e) => { e.stopPropagation(); openLightbox(e.target.src); }; document.getElementById('ref-thumb-3').onclick = (e) => { e.stopPropagation(); openLightbox(e.target.src); }; 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() { document.getElementById('loading-overlay').style.display = 'flex'; } function hideLoading() { document.getElementById('loading-overlay').style.display = 'none'; document.getElementById('progress-stages').style.display = 'none'; document.getElementById('progress-log').innerHTML = ''; document.getElementById('progress-bar-fill').style.width = '0%'; } function showToast(msg) { const t = document.getElementById('toast'); t.textContent = msg; t.style.display = 'block'; setTimeout(() => t.style.display = 'none', 3000); } // Gallery slider & scroll sync with auto-scroll const exGrid = document.getElementById('examples-grid'); const gallerySlider = document.getElementById('gallery-slider'); let isSliderDragging = false; let autoScrollPaused = false; let pauseTimeout = null; const AUTO_SCROLL_SPEED = 0.5; // px per frame function updateSliderFromScroll() { if (isSliderDragging) return; const maxScroll = exGrid.scrollWidth - exGrid.clientWidth; if (maxScroll <= 0) { gallerySlider.value = 0; return; } gallerySlider.value = (exGrid.scrollLeft / maxScroll) * 100; } exGrid.addEventListener('scroll', updateSliderFromScroll); gallerySlider.addEventListener('input', () => { isSliderDragging = true; autoScrollPaused = true; const maxScroll = exGrid.scrollWidth - exGrid.clientWidth; exGrid.scrollTo({ left: (gallerySlider.value / 100) * maxScroll, behavior: 'auto' }); }); gallerySlider.addEventListener('pointerup', () => { isSliderDragging = false; resumeAutoScrollLater(); }); gallerySlider.addEventListener('change', () => { isSliderDragging = false; resumeAutoScrollLater(); }); // Mouse wheel horizontal scroll exGrid.addEventListener('wheel', (e) => { if (Math.abs(e.deltaY) > 0) { e.preventDefault(); exGrid.scrollLeft += e.deltaY; autoScrollPaused = true; resumeAutoScrollLater(); } }, { passive: false }); // Pause on hover exGrid.addEventListener('mouseenter', () => { autoScrollPaused = true; }); exGrid.addEventListener('mouseleave', () => { resumeAutoScrollLater(500); }); function resumeAutoScrollLater(delay = 2000) { clearTimeout(pauseTimeout); pauseTimeout = setTimeout(() => { autoScrollPaused = false; }, delay); } // Auto-scroll loop via requestAnimationFrame function autoScrollLoop() { if (!autoScrollPaused) { const maxScroll = exGrid.scrollWidth - exGrid.clientWidth; if (maxScroll > 0) { exGrid.scrollLeft += AUTO_SCROLL_SPEED; // Loop back to start if (exGrid.scrollLeft >= maxScroll) { exGrid.scrollLeft = 0; } } } requestAnimationFrame(autoScrollLoop); } requestAnimationFrame(autoScrollLoop); init(); </script> </body> </html>
<!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;
min-width: 0;
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 {