Spaces:
Running
Running
| <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; | |
| } | |
| .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> | |
| <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: Camera estimated automatically via MoGe-2. | |
| </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> | |
| </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" } | |
| ]; | |
| 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(); | |
| }; | |
| // 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(); | |
| }; | |
| // 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 { | |
| 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), | |
| 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 instead of SSE | |
| progressInterval = setInterval(async () => { | |
| try { | |
| const resp = await fetch(`/progress?session_id=${sessionId}`); | |
| if (!resp.ok) return; | |
| const data = await resp.json(); | |
| if (data.done) { | |
| stopProgressListener(); | |
| 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 { | |
| 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> |