Files
tencentarc--pixal3d/index.html
T
2026-07-13 13:16:24 +08:00

1493 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
}
.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>
<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>
<a href="https://huggingface.co/spaces/TencentARC/Pixal3D-Server" target="_blank" class="btn btn-outline" style="margin-top: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem; flex-direction: column; gap: 0.25rem;">
<div style="display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="server" style="width: 16px;"></i>
Free Deploy Server
</div>
<div style="display: flex; gap: 0.35rem;">
<span style="background: linear-gradient(135deg, var(--accent), #059669); color: white; font-size: 0.55rem; font-weight: 700; padding: 0.1rem 0.45rem; border-radius: 4px; line-height: 1.4;">★ Recommended</span>
<span style="background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: white; font-size: 0.55rem; font-weight: 700; padding: 0.1rem 0.45rem; border-radius: 4px; line-height: 1.4;">Free</span>
</div>
</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">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">
<p style="font-size: 0.72rem; color: var(--text-dim); line-height: 1.5; 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>
</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.022.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" }
];
async function init() {
lucide.createIcons();
// Set default resolution based on server config (LOW_VRAM → 1024, else 1536)
try {
const cfgResp = await fetch('/app_config');
const cfg = await cfgResp.json();
document.getElementById('resolution').value = cfg.low_vram ? '1024' : '1536';
} catch (e) {
document.getElementById('resolution').value = '1536';
}
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.022.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 {
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 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>