PageSourceSearch

https://vast-ai-triposplat.hf.space/

html vast-ai-triposplat.hf.space collected 2026-10-03 10:21:44 UTC 47,561 bytes, 1,521 lines download raw bytes

1<!DOCTYPE html>
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>TripoSplat — Image to 3D Gaussians</title>
7<meta name="description" content="Convert a single 2D image into high-quality 3D Gaussian splats using TripoSplat by TripoAI.">
8<link rel="preconnect" href="https://fonts.googleapis.com">
9<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
11<style>
12/* ======================================================================
13   DESIGN TOKENS
14   ====================================================================== */
15:root {
16  --bg-base: #08090d;
17  --bg-panel: rgba(13, 15, 22, 0.75);
18  --bg-surface: rgba(255, 255, 255, 0.035);
19  --bg-surface-hover: rgba(255, 255, 255, 0.065);
20  --bg-input: rgba(255, 255, 255, 0.045);
21  --border: rgba(255, 255, 255, 0.06);
22  --border-hover: rgba(255, 255, 255, 0.12);
23  --border-focus: rgba(249, 207, 0, 0.5);
24  --text: #e8eaf0;
25  --text-secondary: #9aa3b2;
26  --text-muted: #6b7488;
27  --text-faint: #4b5366;
28  --accent: #F9CF00;
29  --accent-light: #ffe14d;
30  --accent-dark: #d4af00;
31  --accent-ink: #1a1500;
32  --accent-glow: rgba(249, 207, 0, 0.28);
33  --accent-subtle: rgba(249, 207, 0, 0.1);
34  --success: #34d399;
35  --error: #f87171;
36  --radius-sm: 6px;
37  --radius-md: 10px;
38  --radius-lg: 14px;
39  --radius-xl: 18px;
40  --transition-fast: 0.15s ease;
41  --transition: 0.25s ease;
42  --transition-slow: 0.4s ease;
43  --panel-left-w: 320px;
44  --panel-right-w: 300px;
45  --header-h: 56px;
46}
47
48/* ======================================================================
49   RESET & BASE
50   ====================================================================== */
51*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
52html, body { height: 100%; overflow: hidden; }
53body {
54  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
55  background: var(--bg-base);
56  color: var(--text);
57  -webkit-font-smoothing: antialiased;
58  -moz-osx-font-smoothing: grayscale;
59}
60
61/* ======================================================================
62   BACKGROUND EFFECTS
63   ====================================================================== */
64body::before {
65  content: '';
66  position: fixed;
67  inset: 0;
68  background:
69    radial-gradient(ellipse at 15% 25%, rgba(249, 207, 0, 0.08) 0%, transparent 55%),
70    radial-gradient(ellipse at 85% 75%, rgba(255, 225, 77, 0.05) 0%, transparent 50%),
71    radial-gradient(ellipse at 50% 50%, rgba(60, 50, 8, 0.18) 0%, transparent 70%);
72  pointer-events: none;
73  z-index: 0;
74  animation: bgPulse 25s ease-in-out infinite alternate;
75}
76@keyframes bgPulse {
77  0%   { opacity: 0.6; }
78  100% { opacity: 1; }
79}
80
81/* ======================================================================
82   SCROLLBAR
83   ====================================================================== */
84::-webkit-scrollbar { width: 5px; }
85::-webkit-scrollbar-track { background: transparent; }
86::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
87::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
88
89/* ======================================================================
90   HEADER
91   ====================================================================== */
92#app-header {
93  display: flex;
94  align-items: center;
95  justify-content: space-between;
96  height: var(--header-h);
97  padding: 0 24px;
98  border-bottom: 1px solid var(--border);
99  background: var(--bg-panel);
100  backdrop-filter: blur(24px);
101  -webkit-backdrop-filter: blur(24px);
102  position: relative;
103  z-index: 20;
104}
105.header-left {
106  display: flex;
107  align-items: center;
108  gap: 10px;
109}
110.logo-img {
111  height: 24px;
112  width: auto;
113  object-fit: contain;
114}
115.logo-text {
116  font-size: 18px;
117  font-weight: 700;
118  background: linear-gradient(135deg, #fff0a8, var(--accent) 55%, var(--accent-dark));
119  -webkit-background-clip: text;
120  -webkit-text-fill-color: transparent;
121  background-clip: text;
122  letter-spacing: -0.2px;
123}
124.header-center {
125  position: absolute;
126  left: 50%;
127  transform: translateX(-50%);
128  display: flex;
129  align-items: center;
130  gap: 4px;
131}
132.header-link {
133  padding: 6px 12px;
134  font-size: 12px;
135  font-weight: 500;
136  color: var(--text-secondary);
137  text-decoration: none;
138  border-radius: var(--radius-sm);
139  transition: all var(--transition-fast);
140}
141.header-link:hover {
142  color: var(--accent);
143  background: var(--accent-subtle);
144}
145
146/* ======================================================================
147   MAIN LAYOUT
148   ====================================================================== */
149#app-main {
150  display: flex;
151  height: calc(100vh - var(--header-h));
152  position: relative;
153  z-index: 1;
154}
155
156/* ======================================================================
157   PANELS (shared)
158   ====================================================================== */
159.panel {
160  background: var(--bg-panel);
161  backdrop-filter: blur(24px);
162  -webkit-backdrop-filter: blur(24px);
163}
164.panel-left, .panel-right {
165  display: flex;
166  flex-direction: column;
167  padding: 20px;
168}
169.panel-right {
170  overflow-y: auto;
171}
172.panel-left {
173  width: var(--panel-left-w);
174  min-width: 280px;
175  border-right: 1px solid var(--border);
176  overflow: hidden;            /* the inner area scrolls; button stays pinned */
177}
178/* Scrollable region holding upload / examples / settings. */
179.panel-left-scroll {
180  flex: 1 1 auto;
181  min-height: 0;               /* allow the flex child to shrink so it can scroll */
182  overflow-y: auto;
183  margin-right: -8px;          /* keep content clear of the scrollbar */
184  padding-right: 8px;
185}
186.panel-right {
187  width: var(--panel-right-w);
188  min-width: 260px;
189  border-left: 1px solid var(--border);
190}
191.panel-center {
192  flex: 1;
193  display: flex;
194  flex-direction: column;
195  padding: 20px;
196  min-width: 0;
197  background: transparent;
198  backdrop-filter: none;
199}
200.panel-header {
201  display: flex;
202  align-items: center;
203  justify-content: space-between;
204  margin-bottom: 18px;
205}
206.panel-title {
207  font-size: 14px;
208  font-weight: 600;
209  color: var(--text);
210  letter-spacing: -0.2px;
211}
212.badge {
213  font-size: 10px;
214  font-weight: 600;
215  padding: 3px 8px;
216  border-radius: 20px;
217  background: var(--accent-subtle);
218  color: var(--accent);
219  letter-spacing: 0.3px;
220  text-transform: uppercase;
221}
222
223/* ======================================================================
224   SECTION
225   ====================================================================== */
226.section {
227  margin-bottom: 16px;
228}
229.section-title {
230  font-size: 11px;
231  font-weight: 600;
232  color: var(--text-muted);
233  text-transform: uppercase;
234  letter-spacing: 0.6px;
235  margin-bottom: 10px;
236}
237
238/* ======================================================================
239   UPLOAD AREA
240   ====================================================================== */
241#upload-area {
242  border: 2px dashed rgba(255,255,255,0.08);
243  border-radius: var(--radius-lg);
244  cursor: pointer;
245  transition: all var(--transition);
246  background: var(--bg-surface);
247  margin-bottom: 16px;
248  position: relative;
249  overflow: hidden;
250}
251#upload-area:hover {
252  border-color: rgba(249, 207, 0, 0.4);
253  background: var(--accent-subtle);
254}
255#upload-area.drag-over {
256  border-color: var(--accent);
257  background: rgba(249, 207, 0, 0.14);
258  transform: scale(1.01);
259}
260#upload-area.has-image {
261  border-style: solid;
262  border-color: var(--border);
263  padding: 0;
264}
265.upload-placeholder {
266  display: flex;
267  flex-direction: column;
268  align-items: center;
269  justify-content: center;
270  padding: 36px 16px;
271  gap: 8px;
272}
273.upload-icon {
274  width: 36px; height: 36px;
275  color: var(--text-muted);
276  margin-bottom: 4px;
277}
278.upload-text {
279  font-size: 13px;
280  font-weight: 500;
281  color: var(--text-secondary);
282}
283.upload-hint {
284  font-size: 11px;
285  color: var(--text-muted);
286}
287.upload-preview {
288  position: relative;
289}
290.upload-preview img {
291  width: 100%;
292  display: block;
293  border-radius: calc(var(--radius-lg) - 2px);
294}
295.btn-clear {
296  position: absolute;
297  top: 8px; right: 8px;
298  width: 28px; height: 28px;
299  border-radius: 50%;
300  border: none;
301  background: rgba(0,0,0,0.65);
302  backdrop-filter: blur(8px);
303  color: var(--text-secondary);
304  cursor: pointer;
305  display: flex;
306  align-items: center;
307  justify-content: center;
308  transition: all var(--transition-fast);
309  z-index: 2;
310}
311.btn-clear:hover {
312  background: rgba(248, 113, 113, 0.5);
313  color: #fff;
314}
315.btn-clear svg {
316  width: 14px; height: 14px;
317}
318
319/* ======================================================================
320   EXAMPLES
321   ====================================================================== */
322.examples-grid {
323  display: grid;
324  grid-template-columns: repeat(4, 1fr);
325  gap: 8px;
326  margin-bottom: 16px;
327}
328.example-thumb {
329  aspect-ratio: 1;
330  border-radius: var(--radius-md);
331  overflow: hidden;
332  cursor: pointer;
333  border: 2px solid transparent;
334  transition: all var(--transition);
335  position: relative;
336}
337.example-thumb:hover {
338  border-color: rgba(249, 207, 0, 0.4);
339  transform: scale(1.06);
340  box-shadow: 0 4px 16px rgba(0,0,0,0.3);
341}
342.example-thumb.selected {
343  border-color: var(--accent);
344  box-shadow: 0 0 0 1px var(--accent), 0 0 12px var(--accent-glow);
345}
346.example-thumb img {
347  width: 100%;
348  height: 100%;
349  object-fit: cover;
350  display: block;
351}
352
353/* ======================================================================
354   ACCORDION
355   ====================================================================== */
356.accordion-btn {
357  width: 100%;
358  display: flex;
359  align-items: center;
360  justify-content: space-between;
361  padding: 10px 12px;
362  background: var(--bg-surface);
363  border: 1px solid var(--border);
364  border-radius: var(--radius-md);
365  color: var(--text-secondary);
366  font-family: inherit;
367  font-size: 12px;
368  font-weight: 500;
369  cursor: pointer;
370  transition: all var(--transition-fast);
371}
372.accordion-btn:hover {
373  background: var(--bg-surface-hover);
374  color: var(--text);
375}
376.accordion-icon {
377  width: 16px; height: 16px;
378  transition: transform var(--transition);
379}
380.accordion-icon.rotated {
381  transform: rotate(180deg);
382}
383.accordion-body {
384  max-height: 0;
385  overflow: hidden;
386  transition: max-height var(--transition-slow) ease, padding var(--transition-slow) ease;
387  padding: 0 4px;
388}
389.accordion-body.open {
390  max-height: 400px;
391  padding: 14px 4px 4px;
392}
393
394/* ======================================================================
395   FORM CONTROLS
396   ====================================================================== */
397.form-group {
398  margin-bottom: 14px;
399}
400.form-group label {
401  display: flex;
402  align-items: center;
403  justify-content: space-between;
404  font-size: 11px;
405  font-weight: 500;
406  color: var(--text-muted);
407  text-transform: uppercase;
408  letter-spacing: 0.5px;
409  margin-bottom: 6px;
410}
411.value-display {
412  font-size: 11px;
413  font-weight: 600;
414  color: var(--accent);
415  font-variant-numeric: tabular-nums;
416  background: var(--accent-subtle);
417  padding: 1px 6px;
418  border-radius: 4px;
419}
420input[type="number"],
421select {
422  width: 100%;
423  padding: 9px 12px;
424  background: var(--bg-input);
425  border: 1px solid var(--border);
426  border-radius: var(--radius-sm);
427  color: var(--text);
428  font-family: inherit;
429  font-size: 13px;
430  outline: none;
431  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
432}
433input[type="number"]:focus,
434select:focus {
435  border-color: var(--border-focus);
436  box-shadow: 0 0 0 3px rgba(249, 207, 0, 0.12);
437}
438select {
439  appearance: none;
440  -webkit-appearance: none;
441  cursor: pointer;
442  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
443  background-repeat: no-repeat;
444  background-position: right 10px center;
445  padding-right: 32px;
446}
447
448/* Range slider */
449input[type="range"] {
450  -webkit-appearance: none;
451  appearance: none;
452  width: 100%;
453  height: 4px;
454  background: rgba(255,255,255,0.08);
455  border-radius: 2px;
456  outline: none;
457  cursor: pointer;
458}
459input[type="range"]::-webkit-slider-thumb {
460  -webkit-appearance: none;
461  width: 16px; height: 16px;
462  background: var(--accent);
463  border-radius: 50%;
464  border: 2.5px solid var(--bg-base);
465  box-shadow: 0 0 8px var(--accent-glow), 0 1px 3px rgba(0,0,0,0.3);
466  cursor: grab;
467  transition: transform var(--transition-fast), box-shadow var(--transition-fast);
468}
469input[type="range"]::-webkit-slider-thumb:hover {
470  transform: scale(1.15);
471  box-shadow: 0 0 14px var(--accent-glow), 0 1px 3px rgba(0,0,0,0.3);
472}
473input[type="range"]::-moz-range-thumb {
474  width: 16px; height: 16px;
475  background: var(--accent);
476  border-radius: 50%;
477  border: 2.5px solid var(--bg-base);
478  box-shadow: 0 0 8px var(--accent-glow);
479  cursor: grab;
480}
481
482/* ======================================================================
483   GENERATE BUTTON
484   ====================================================================== */
485.btn-primary {
486  width: 100%;
487  padding: 13px 16px;
488  margin-top: 16px;
489  background: linear-gradient(135deg, var(--accent-light) 0%, var(--accent) 55%, var(--accent-dark) 100%);
490  border: none;
491  border-radius: var(--radius-md);
492  color: var(--accent-ink);
493  font-family: inherit;
494  font-size: 14px;
495  font-weight: 700;
496  cursor: pointer;
497  display: flex;
498  align-items: center;
499  justify-content: center;
500  gap: 8px;
501  transition: all var(--transition);
502  position: relative;
503  overflow: hidden;
504  flex-shrink: 0;
505}
506.btn-primary::before {
507  content: '';
508  position: absolute;
509  inset: 0;
510  background: linear-gradient(120deg, transparent 25%, rgba(255,255,255,0.4) 50%, transparent 75%);
511  transform: translateX(-120%);
512  transition: transform 0.65s ease;
513}
514.btn-primary:hover:not(:disabled)::before {
515  transform: translateX(120%);
516}
517.btn-primary:hover:not(:disabled) {
518  box-shadow: 0 6px 24px rgba(249, 207, 0, 0.38), 0 0 0 1px rgba(249, 207, 0, 0.25);
519  transform: translateY(-1px);
520}
521.btn-primary:active:not(:disabled) {
522  transform: translateY(0);
523}
524.btn-primary:disabled {
525  opacity: 0.4;
526  cursor: not-allowed;
527}
528.btn-primary.loading {
529  background: rgba(255, 255, 255, 0.06);   /* unfilled track */
530  color: #fffdf0;
531  opacity: 1;                               /* stay solid while disabled */
532  pointer-events: none;
533}
534.btn-primary.loading #generate-label {
535  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.6);
536  letter-spacing: 0.2px;
537  font-variant-numeric: tabular-nums;
538}
539/* Fill layer turns the button itself into the progress bar. */
540.btn-progress-fill {
541  position: absolute;
542  left: 0; top: 0; bottom: 0;
543  width: 0%;
544  border-radius: inherit;
545  background: linear-gradient(90deg, #a87f08, var(--accent-dark) 55%, var(--accent));
546  box-shadow: 0 0 18px var(--accent-glow);
547  transition: width 0.35s ease;
548  opacity: 0;
549  z-index: 0;
550}
551.btn-primary.loading .btn-progress-fill {
552  opacity: 1;
553}
554/* Keep the icon, label and spinner above the fill. */
555.btn-primary > .btn-icon,
556.btn-primary > #generate-label {
557  position: relative;
558  z-index: 1;
559}
560.btn-icon {
561  width: 16px; height: 16px;
562  flex-shrink: 0;
563}
564
565/* ======================================================================
566   VIEWER
567   ====================================================================== */
568.viewer-wrap {
569  flex: 1;
570  border-radius: var(--radius-xl);
571  overflow: hidden;
572  position: relative;
573  background: radial-gradient(ellipse at 50% 55%, #151a2e 0%, #0a0b10 100%);
574  border: 1px solid var(--border);
575  transition: box-shadow var(--transition-slow);
576}
577.viewer-wrap.active {
578  box-shadow: 0 0 40px rgba(249, 207, 0, 0.1), 0 0 80px rgba(255, 225, 77, 0.05);
579}
580.viewer-placeholder {
581  display: flex;
582  flex-direction: column;
583  align-items: center;
584  justify-content: center;
585  height: 100%;
586  gap: 14px;
587  color: var(--text-faint);
588  user-select: none;
589}
590.placeholder-icon {
591  width: 56px; height: 56px;
592  opacity: 0.35;
593}
594.viewer-placeholder p {
595  font-size: 14px;
596  font-weight: 500;
597}
598.viewer-placeholder .hint {
599  font-size: 12px;
600  color: var(--text-muted);
601  opacity: 0.6;
602}
603#viewer-iframe {
604  width: 100%;
605  height: 100%;
606  border: none;
607  position: absolute;
608  inset: 0;
609}
610
611/* ======================================================================
612   GENERATION PROGRESS OVERLAY (inside the viewer)
613   ====================================================================== */
614.gen-progress {
615  position: absolute;
616  inset: 0;
617  z-index: 6;
618  display: flex;
619  flex-direction: column;
620  align-items: center;
621  justify-content: center;
622  gap: 16px;
623  padding: 0 48px;
624  background: rgba(8, 9, 13, 0.62);
625  backdrop-filter: blur(6px);
626  -webkit-backdrop-filter: blur(6px);
627  opacity: 1;
628  transition: opacity 0.9s ease;   /* graceful fade-out when generation ends */
629}
630.gen-progress.fading-out {
631  opacity: 0;
632}
633/* Ink-splat animation fills the whole overlay, behind the bar/text. */
634.gen-splat-canvas {
635  position: absolute;
636  inset: 0;
637  width: 100%;
638  height: 100%;
639  display: block;
640  z-index: 0;
641  pointer-events: none;
642}
643
644/* ======================================================================
645   OUTPUT PANEL
646   ====================================================================== */
647.output-placeholder {
648  display: flex;
649  flex-direction: column;
650  align-items: center;
651  justify-content: center;
652  padding: 48px 16px;
653  text-align: center;
654  gap: 12px;
655  color: var(--text-faint);
656}
657.output-placeholder .placeholder-icon {
658  width: 40px; height: 40px;
659  opacity: 0.25;
660}
661.output-placeholder p {
662  font-size: 13px;
663}
664
665/* Info card */
666.info-card {
667  background: var(--bg-surface);
668  border: 1px solid var(--border);
669  border-radius: var(--radius-md);
670  padding: 4px 0;
671  margin-bottom: 16px;
672}
673.info-row {
674  display: flex;
675  align-items: center;
676  justify-content: space-between;
677  padding: 10px 14px;
678}
679.info-row + .info-row {
680  border-top: 1px solid rgba(255,255,255,0.03);
681}
682.info-key {
683  font-size: 11px;
684  font-weight: 500;
685  color: var(--text-muted);
686  text-transform: uppercase;
687  letter-spacing: 0.5px;
688}
689.info-val {
690  font-size: 13px;
691  font-weight: 600;
692  color: var(--text);
693  font-variant-numeric: tabular-nums;
694}
695
696/* Download button */
697.btn-download {
698  display: flex;
699  align-items: center;
700  justify-content: center;
701  gap: 8px;
702  width: 100%;
703  padding: 11px 16px;
704  background: var(--bg-surface);
705  border: 1px solid var(--border);
706  border-radius: var(--radius-md);
707  color: var(--text);
708  font-family: inherit;
709  font-size: 13px;
710  font-weight: 500;
711  cursor: pointer;
712  transition: all var(--transition);
713  text-decoration: none;
714  margin-bottom: 20px;
715}
716.btn-download:hover {
717  background: var(--bg-surface-hover);
718  border-color: var(--border-hover);
719  box-shadow: 0 2px 12px rgba(0,0,0,0.2);
720}
721.btn-download svg {
722  width: 16px; height: 16px;
723}
724
725/* Preprocessed image */
726.preprocessed-section img {
727  width: 100%;
728  border-radius: var(--radius-md);
729  margin-top: 10px;
730  border: 1px solid var(--border);
731}
732
733/* Status bar */
734.status-bar {
735  margin-top: auto;
736  display: flex;
737  align-items: center;
738  gap: 8px;
739  padding: 10px 0 0;
740  flex-shrink: 0;
741  transition: opacity var(--transition-slow);
742}
743.status-bar.fade-out {
744  opacity: 0;
745}
746.status-dot {
747  width: 7px; height: 7px;
748  border-radius: 50%;
749  flex-shrink: 0;
750}
751.status-dot.connecting {
752  background: #fbbf24;
753  animation: dotPulse 1.2s ease infinite;
754}
755.status-dot.connected {
756  background: var(--success);
757}
758.status-dot.error {
759  background: var(--error);
760}
761@keyframes dotPulse {
762  0%, 100% { opacity: 1; }
763  50% { opacity: 0.3; }
764}
765.status-text {
766  font-size: 11px;
767  color: var(--text-muted);
768}
769
770/* ======================================================================
771   ERROR TOAST
772   ====================================================================== */
773.error-toast {
774  position: fixed;
775  bottom: 24px;
776  left: 50%;
777  transform: translateX(-50%);
778  padding: 12px 24px;
779  background: rgba(127, 29, 29, 0.9);
780  backdrop-filter: blur(12px);
781  border: 1px solid rgba(248, 113, 113, 0.3);
782  border-radius: var(--radius-md);
783  color: #fca5a5;
784  font-size: 13px;
785  font-weight: 500;
786  z-index: 100;
787  box-shadow: 0 8px 32px rgba(0,0,0,0.4);
788  white-space: nowrap;
789  max-width: 90vw;
790  overflow: hidden;
791  text-overflow: ellipsis;
792}
793
794/* ======================================================================
795   ANIMATIONS
796   ====================================================================== */
797@keyframes fadeIn {
798  from { opacity: 0; transform: translateY(8px); }
799  to   { opacity: 1; transform: translateY(0); }
800}
801.fade-in { animation: fadeIn 0.35s ease forwards; }
802
803@keyframes spin {
804  to { transform: rotate(360deg); }
805}
806.spin { animation: spin 0.9s linear infinite; }
807
808/* ======================================================================
809   UTILITIES
810   ====================================================================== */
811.hidden { display: none !important; }
812
813/* ======================================================================
814   RESPONSIVE
815   ====================================================================== */
816@media (max-width: 960px) {
817  #app-main { flex-direction: column; overflow-y: auto; }
818  .panel-left, .panel-right {
819    width: 100%;
820    min-width: auto;
821    border-right: none;
822    border-left: none;
823    border-bottom: 1px solid var(--border);
824  }
825  .panel-center { min-height: 400px; }
826  /* Let the left panel flow with the page instead of an inner scroll. */
827  .panel-left { overflow: visible; }
828  .panel-left-scroll { overflow: visible; min-height: 0; }
829  :root {
830    --panel-left-w: 100%;
831    --panel-right-w: 100%;
832  }
833}
834</style>
835</head>
836<body>
837
838<!-- =====================================================================
839     HEADER
840     ===================================================================== -->
841<header id="app-header">
842  <div class="header-left">
843    <img class="logo-img" src="https://cdn-web.tripo3d.ai/tripo-web/logo/tripo-logo1.webp" alt="Tripo AI Logo">
844    <span class="logo-text">TripoSplat</span>
845  </div>
846  <div class="header-center">
847    <a class="header-link" href="https://arxiv.org/abs/2605.16355" target="_blank" rel="noopener">Paper</a>
848    <a class="header-link" href="https://www.tripo3d.ai/research/triposplat" target="_blank" rel="noopener">Blog</a>
849    <a class="header-link" href="https://github.com/VAST-AI-Research/TripoSplat" target="_blank" rel="noopener">GitHub</a>
850  </div>
851</header>
852
853<!-- =====================================================================
854     MAIN
855     ===================================================================== -->
856<main id="app-main">
857
858  <!-- =============== LEFT PANEL =============== -->
859  <aside class="panel panel-left" id="panel-left">
860    <!-- Scrollable content; the Generate button below stays pinned -->
861    <div class="panel-left-scroll">
862
863    <!-- Upload area -->
864    <div id="upload-area">
865      <input type="file" id="file-input" accept="image/*" hidden>
866      <div class="upload-placeholder" id="upload-placeholder">
867        <svg class="upload-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
868          <path d="M12 16V4"/>
869          <path d="M8 8l4-4 4 4"/>
870          <path d="M3 17l.621 2.485A2 2 0 0 0 5.561 21h12.878a2 2 0 0 0 1.94-1.515L21 17"/>
871        </svg>
872        <p class="upload-text">Drop image or click to upload</p>
873        <p class="upload-hint">PNG, JPG, WEBP up to 20 MB</p>
874      </div>
875      <div class="upload-preview hidden" id="upload-preview">
876        <img id="preview-image" alt="Preview">
877        <button class="btn-clear" id="clear-btn" title="Remove image">
878          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
879        </button>
880      </div>
881    </div>
882
883    <!-- Examples -->
884    <div class="section">
885      <h3 class="section-title">Examples</h3>
886      <div class="examples-grid" id="examples-grid"></div>
887    </div>
888
889    <!-- Settings accordion -->
890    <div class="section">
891      <button class="accordion-btn" id="settings-toggle" type="button">
892        <span>Sampling Settings</span>
893        <svg class="accordion-icon" id="settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
894      </button>
895      <div class="accordion-body" id="settings-body">
896        <div class="form-group">
897          <label for="seed">Seed</label>
898          <input type="number" id="seed" value="42" min="0" max="999999999">
899        </div>
900        <div class="form-group">
901          <label for="steps">Inference Steps <span class="value-display" id="steps-val">20</span></label>
902          <input type="range" id="steps" min="1" max="50" value="20" step="1">
903        </div>
904        <div class="form-group">
905          <label for="guidance">Guidance Scale <span class="value-display" id="guidance-val">3.0</span></label>
906          <input type="range" id="guidance" min="1" max="10" value="3" step="0.5">
907        </div>
908        <div class="form-group">
909          <label for="gaussians">Number of Gaussians</label>
910          <select id="gaussians">
911            <option value="32768">32,768</option>
912            <option value="65536">65,536</option>
913            <option value="131072">131,072</option>
914            <option value="262144" selected>262,144</option>
915          </select>
916        </div>
917        <div class="form-group">
918          <label for="format">Download Format</label>
919          <select id="format">
920            <option value="ply" selected>PLY</option>
921            <option value="splat">SPLAT</option>
922          </select>
923        </div>
924      </div>
925    </div>
926
927    </div><!-- /.panel-left-scroll -->
928
929    <!-- Generate button (always pinned to the bottom) -->
930    <button class="btn-primary" id="generate-btn" type="button" disabled>
931      <span class="btn-progress-fill" id="generate-fill"></span>
932      <svg class="btn-icon" id="generate-icon" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0L14.59 8.41 23 12 14.59 15.59 12 24 9.41 15.59 1 12 9.41 8.41Z"/></svg>
933      <span id="generate-label">Generate</span>
934      <svg class="btn-icon spin hidden" id="generate-spinner" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 2a10 10 0 0 1 10 10"/></svg>
935    </button>
936  </aside>
937
938  <!-- =============== CENTER PANEL =============== -->
939  <section class="panel panel-center" id="panel-center">
940    <div class="viewer-wrap" id="viewer-wrap">
941      <div class="viewer-placeholder" id="viewer-placeholder">
942        <svg class="placeholder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
943          <path d="M12 2L3 7v10l9 5 9-5V7l-9-5z"/>
944          <path d="M12 22V12"/>
945          <path d="M3 7l9 5"/>
946          <path d="M21 7l-9 5"/>
947        </svg>
948        <p>3D viewer will appear here after generation</p>
949        <p class="hint">Upload an image and click Generate</p>
950      </div>
951      <iframe id="viewer-iframe" class="hidden" allowfullscreen></iframe>
952      <div class="gen-progress hidden" id="gen-progress">
953        <canvas class="gen-splat-canvas" id="gen-splat-canvas"></canvas>
954      </div>
955    </div>
956  </section>
957
958  <!-- =============== RIGHT PANEL =============== -->
959  <aside class="panel panel-right" id="panel-right">
960    <div class="panel-header">
961      <h2 class="panel-title">Output</h2>
962    </div>
963
964    <!-- Placeholder -->
965    <div class="output-placeholder" id="output-placeholder">
966      <svg class="placeholder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">
967        <rect x="3" y="3" width="18" height="18" rx="3"/>
968        <path d="M9 9h.01M15 9h.01"/>
969        <path d="M8 14s1.5 2 4 2 4-2 4-2"/>
970      </svg>
971      <p>Generation results<br>will appear here</p>
972    </div>
973
974    <!-- Results (hidden until generation) -->
975    <div class="hidden" id="output-content">
976      <div class="info-card">
977        <div class="info-row">
978          <span class="info-key">Gaussians</span>
979          <span class="info-val" id="out-gaussians">—</span>
980        </div>
981        <div class="info-row">
982          <span class="info-key">Time</span>
983          <span class="info-val" id="out-time">—</span>
984        </div>
985        <div class="info-row">
986          <span class="info-key">File</span>
987          <span class="info-val" id="out-file">—</span>
988        </div>
989      </div>
990
991      <a class="btn-download" id="download-btn" download>
992        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
993          <path d="M12 4v12"/>
994          <path d="M8 12l4 4 4-4"/>
995          <path d="M3 17l.621 2.485A2 2 0 0 0 5.561 21h12.878a2 2 0 0 0 1.94-1.515L21 17"/>
996        </svg>
997        <span>Download</span>
998      </a>
999
1000      <div class="preprocessed-section">
1001        <h3 class="section-title">Preprocessed Input</h3>
1002        <img id="preprocessed-img" alt="Preprocessed input">
1003      </div>
1004    </div>
1005
1006    <!-- Connection status -->
1007    <div class="status-bar" id="status-bar">
1008      <span class="status-dot connecting" id="status-dot"></span>
1009      <span class="status-text" id="status-text">Connecting…</span>
1010    </div>
1011  </aside>
1012
1013</main>
1014
1015<!-- =====================================================================
1016     JAVASCRIPT
1017     ===================================================================== -->
1018<script type="module">
1019import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
1020
1021// --------------------------------------------------------------------------
1022// State
1023// --------------------------------------------------------------------------
1024let client = null;
1025let currentFile = null;
1026let isGenerating = false;
1027
1028// --------------------------------------------------------------------------
1029// DOM helpers
1030// --------------------------------------------------------------------------
1031const $ = (id) => document.getElementById(id);
1032
1033// --------------------------------------------------------------------------
1034// Initialise
1035// --------------------------------------------------------------------------
1036async function init() {
1037  updateStatus("connecting");
1038
1039  try {
1040    // Subscribe to "status" events too (default is ["data"] only) so the
1041    // submit() iterator delivers gr.Progress() updates, not just the result.
1042    client = await Client.connect(window.location.origin, { events: ["data", "status"] });
1043    updateStatus("connected");
1044  } catch (e) {
1045    updateStatus("error", e.message);
1046    console.error("Gradio client connection failed:", e);
1047  }
1048
1049  loadExamples();
1050  setupUpload();
1051  setupSettings();
1052  setupGenerate();
1053}
1054
1055// --------------------------------------------------------------------------
1056// Example images
1057// --------------------------------------------------------------------------
1058async function loadExamples() {
1059  try {
1060    const res = await fetch("/api/examples");
1061    const examples = await res.json();
1062    const grid = $("examples-grid");
1063
1064    examples.forEach((ex, i) => {
1065      const thumb = document.createElement("div");
1066      thumb.className = "example-thumb";
1067      thumb.innerHTML = `<img src="${ex.url}" alt="${ex.name}" loading="lazy" draggable="false">`;
1068      thumb.addEventListener("click", () => selectExample(ex, i));
1069      grid.appendChild(thumb);
1070    });
1071  } catch (e) {
1072    console.warn("Failed to load examples:", e);
1073  }
1074}
1075
1076async function selectExample(ex, idx) {
1077  try {
1078    const res = await fetch(ex.url);
1079    const blob = await res.blob();
1080    currentFile = new File([blob], `example_${idx}.webp`, { type: "image/webp" });
1081    showPreview(URL.createObjectURL(blob));
1082
1083    // Highlight the selected thumbnail
1084    document.querySelectorAll(".example-thumb").forEach((t, i) => {
1085      t.classList.toggle("selected", i === idx);
1086    });
1087  } catch (e) {
1088    console.error("Failed to load example:", e);
1089  }
1090}
1091
1092// --------------------------------------------------------------------------
1093// File upload / drag & drop
1094// --------------------------------------------------------------------------
1095function setupUpload() {
1096  const area = $("upload-area");
1097  const input = $("file-input");
1098
1099  area.addEventListener("click", (e) => {
1100    // Don't trigger if clicking the clear button
1101    if (e.target.closest(".btn-clear")) return;
1102    input.click();
1103  });
1104
1105  input.addEventListener("change", () => {
1106    if (input.files[0]) handleFile(input.files[0]);
1107  });
1108
1109  area.addEventListener("dragover", (e) => {
1110    e.preventDefault();
1111    area.classList.add("drag-over");
1112  });
1113  area.addEventListener("dragleave", () => {
1114    area.classList.remove("drag-over");
1115  });
1116  area.addEventListener("drop", (e) => {
1117    e.preventDefault();
1118    area.classList.remove("drag-over");
1119    const file = e.dataTransfer.files[0];
1120    if (file && file.type.startsWith("image/")) handleFile(file);
1121  });
1122
1123  $("clear-btn").addEventListener("click", (e) => {
1124    e.stopPropagation();
1125    clearFile();
1126  });
1127}
1128
1129function handleFile(file) {
1130  if (!file.type.startsWith("image/")) return;
1131  currentFile = file;
1132  showPreview(URL.createObjectURL(file));
1133  // De-select example thumbnails
1134  document.querySelectorAll(".example-thumb").forEach((t) => t.classList.remove("selected"));
1135}
1136
1137function showPreview(url) {
1138  $("preview-image").src = url;
1139  $("upload-placeholder").classList.add("hidden");
1140  $("upload-preview").classList.remove("hidden");
1141  $("upload-area").classList.add("has-image");
1142  $("generate-btn").disabled = false;
1143}
1144
1145function clearFile() {
1146  currentFile = null;
1147  $("file-input").value = "";
1148  $("upload-placeholder").classList.remove("hidden");
1149  $("upload-preview").classList.add("hidden");
1150  $("upload-area").classList.remove("has-image");
1151  $("generate-btn").disabled = true;
1152  document.querySelectorAll(".example-thumb").forEach((t) => t.classList.remove("selected"));
1153}
1154
1155// --------------------------------------------------------------------------
1156// Settings accordion & slider labels
1157// --------------------------------------------------------------------------
1158function setupSettings() {
1159  const toggle = $("settings-toggle");
1160  const body = $("settings-body");
1161  const icon = $("settings-icon");
1162  let open = false;
1163
1164  toggle.addEventListener("click", () => {
1165    open = !open;
1166    body.classList.toggle("open", open);
1167    icon.classList.toggle("rotated", open);
1168  });
1169
1170  $("steps").addEventListener("input", (e) => {
1171    $("steps-val").textContent = e.target.value;
1172  });
1173  $("guidance").addEventListener("input", (e) => {
1174    $("guidance-val").textContent = parseFloat(e.target.value).toFixed(1);
1175  });
1176}
1177
1178// --------------------------------------------------------------------------
1179// Generation
1180// --------------------------------------------------------------------------
1181function setupGenerate() {
1182  $("generate-btn").addEventListener("click", generate);
1183}
1184
1185async function generate() {
1186  if (!currentFile || isGenerating || !client) return;
1187
1188  isGenerating = true;
1189  const btn = $("generate-btn");
1190  btn.disabled = true;
1191  btn.classList.add("loading");
1192  $("generate-label").textContent = "Generating…";
1193  $("generate-icon").classList.add("hidden");
1194  $("generate-spinner").classList.remove("hidden");
1195  showProgress();
1196
1197  try {
1198    // Use submit() (not predict()) so we can stream the server-side
1199    // gr.Progress() updates that arrive as "status" events.
1200    const submission = client.submit("/generate", {
1201      image: handle_file(currentFile),
1202      seed: parseInt($("seed").value) || 42,
1203      steps: parseInt($("steps").value) || 20,
1204      guidance_scale: parseFloat($("guidance").value) || 3.0,
1205      num_gaussians: parseInt($("gaussians").value) || 262144,
1206      output_format: $("format").value || "ply",
1207    });
1208
1209    let result = null;
1210    for await (const msg of submission) {
1211      if (msg.type === "status") {
1212        if (msg.stage === "error") {
1213          throw new Error(msg.message || "Generation failed.");
1214        }
1215        const pd = msg.progress_data || (msg.status && msg.status.progress_data);
1216        if (pd && pd.length) {
1217          const p = pd[pd.length - 1];
1218          let frac = null;
1219          if (typeof p.progress === "number") {
1220            frac = p.progress;
1221          } else if (typeof p.index === "number" && p.length) {
1222            frac = p.index / p.length;
1223          }
1224          updateProgress(frac, p.desc);
1225        }
1226      } else if (msg.type === "data") {
1227        result = msg;
1228      }
1229    }
1230
1231    if (!result) throw new Error("No result returned from server.");
1232    updateProgress(1, "Done");
1233    showResults(result);
1234  } catch (error) {
1235    console.error("Generation failed:", error);
1236    showError(error.message || "Generation failed. Please try again.");
1237  } finally {
1238    isGenerating = false;
1239    btn.disabled = false;
1240    btn.classList.remove("loading");
1241    $("generate-label").textContent = "Generate";
1242    $("generate-icon").classList.remove("hidden");
1243    $("generate-spinner").classList.add("hidden");
1244    hideProgress();
1245  }
1246}
1247
1248// --------------------------------------------------------------------------
1249// "Splat" progress animation — Splatoon-style ink blobs keep dropping onto
1250// the screen, overlapping. Capped at SPLAT_MAX; the oldest then fades away.
1251// --------------------------------------------------------------------------
1252const SPLAT_COLORS = ["#fff0a8", "#ffe14d", "#F9CF00", "#e9b400", "#d4af00"];
1253const SPLAT_MAX = 128;         // max simultaneously-visible splats
1254const SPLAT_GAP_MIN = 130;     // min gap between drops (ms)
1255const SPLAT_GAP_MAX = 820;     // max gap between drops (ms) — intervals vary, not fixed
1256const SPLAT_POP_MS = 240;      // "drop onto screen" pop-in
1257const SPLAT_FADE_MS = 700;     // fade-out once retired
1258const SPLAT_CLOSE_MS = 900;    // overlay fade-out after generation finishes
1259
1260const _splat = { raf: null, items: [], lastSpawn: 0, nextGap: 0, closing: false, closeTimer: null };
1261
1262function _easeOutBack(t) {
1263  const c1 = 1.70158, c3 = c1 + 1;
1264  return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
1265}
1266
1267// Build one irregular ink-splat shape (unit scale, centred at the origin):
1268// a lumpy main blob plus a scatter of satellite droplets and far specks.
1269function _makeSplatPath() {
1270  const path = new Path2D();
1271  const n = 9 + Math.floor(Math.random() * 5);
1272  const baseR = 0.72 + Math.random() * 0.18;
1273  const pts = [];
1274  for (let i = 0; i < n; i++) {
1275    const a = (i / n) * Math.PI * 2;
1276    let r = baseR * (0.78 + Math.random() * 0.5);
1277    if (Math.random() < 0.25) r *= 1.3 + Math.random() * 0.5;   // tendril / spike
1278    pts.push([Math.cos(a) * r, Math.sin(a) * r]);
1279  }
1280  const mid = (p, q) => [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2];
1281  let m = mid(pts[n - 1], pts[0]);
1282  path.moveTo(m[0], m[1]);
1283  for (let i = 0; i < n; i++) {                                 // smooth closed blob
1284    const cur = pts[i], nxt = pts[(i + 1) % n], mm = mid(cur, nxt);
1285    path.quadraticCurveTo(cur[0], cur[1], mm[0], mm[1]);
1286  }
1287  path.closePath();
1288  const blobs = 3 + Math.floor(Math.random() * 4);              // droplets
1289  for (let i = 0; i < blobs; i++) {
1290    const a = Math.random() * Math.PI * 2;
1291    const d = baseR * (1.05 + Math.random() * 0.8);
1292    const dx = Math.cos(a) * d, dy = Math.sin(a) * d;
1293    const rr = 0.06 + Math.random() * 0.17;
1294    path.moveTo(dx + rr, dy);
1295    path.arc(dx, dy, rr, 0, Math.PI * 2);
1296  }
1297  const dots = Math.floor(Math.random() * 3);                   // far specks
1298  for (let i = 0; i < dots; i++) {
1299    const a = Math.random() * Math.PI * 2;
1300    const d = baseR * (1.7 + Math.random() * 0.7);
1301    const dx = Math.cos(a) * d, dy = Math.sin(a) * d;
1302    const rr = 0.03 + Math.random() * 0.05;
1303    path.moveTo(dx + rr, dy);
1304    path.arc(dx, dy, rr, 0, Math.PI * 2);
1305  }
1306  return path;
1307}
1308
1309function _spawnSplat(now) {
1310  _splat.items.push({
1311    fx: 0.07 + Math.random() * 0.86,         // position as a fraction of the canvas
1312    fy: 0.10 + Math.random() * 0.80,
1313    rot: Math.random() * Math.PI * 2,
1314    sizeFrac: 0.04 + Math.pow(Math.random(), 1.8) * 0.15,  // small base, wide variance
1315    color: SPLAT_COLORS[Math.floor(Math.random() * SPLAT_COLORS.length)],
1316    baseAlpha: 0.30 + Math.random() * 0.14,  // semi-transparent so overlaps blend
1317    path: _makeSplatPath(),
1318    birth: now,
1319    fading: false,
1320    fadeStart: 0,
1321  });
1322}
1323
1324function _splatFrame(now) {
1325  const cv = $("gen-splat-canvas");
1326  if (!cv) return;
1327  const ctx = cv.getContext("2d");
1328  const dpr = Math.min(window.devicePixelRatio || 1, 2);
1329  const W = Math.round((cv.clientWidth || 320) * dpr);
1330  const H = Math.round((cv.clientHeight || 200) * dpr);
1331  if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; }
1332  const unit = Math.min(W, H);
1333
1334  // Drop a burst of splats after a randomised gap, so the rhythm stays dynamic.
1335  if (!_splat.closing && now - _splat.lastSpawn > _splat.nextGap) {
1336    const burst = 1 + Math.floor(Math.pow(Math.random(), 2) * 4);  // 1–4, biased to fewer
1337    for (let k = 0; k < burst; k++) _spawnSplat(now);
1338    _splat.lastSpawn = now;
1339    _splat.nextGap = SPLAT_GAP_MIN + Math.random() * (SPLAT_GAP_MAX - SPLAT_GAP_MIN);
1340  }
1341  // Cap the count: retire the oldest still-active splat.
1342  const active = _splat.items.filter(s => !s.fading);
1343  if (active.length > SPLAT_MAX) {
1344    active[0].fading = true;
1345    active[0].fadeStart = now;
1346  }
1347
1348  ctx.clearRect(0, 0, W, H);
1349  ctx.globalCompositeOperation = "source-over";
1350
1351  const keep = [];
1352  for (const s of _splat.items) {                  // oldest first → newest overlaps on top
1353    const pop = Math.min((now - s.birth) / SPLAT_POP_MS, 1);
1354    let fade = 1;
1355    if (s.fading) {
1356      fade = 1 - (now - s.fadeStart) / SPLAT_FADE_MS;
1357      if (fade <= 0) continue;                     // fully gone → drop it
1358    }
1359    keep.push(s);
1360    const scale = s.sizeFrac * unit * (0.6 + 0.4 * _easeOutBack(pop));
1361    ctx.save();
1362    ctx.translate(s.fx * W, s.fy * H);
1363    ctx.rotate(s.rot);
1364    ctx.scale(scale, scale);
1365    ctx.globalAlpha = s.baseAlpha * pop * fade;
1366    ctx.fillStyle = s.color;
1367    ctx.fill(s.path);
1368    ctx.restore();
1369  }
1370  _splat.items = keep;
1371  ctx.globalAlpha = 1;
1372
1373  _splat.raf = requestAnimationFrame(_splatFrame);
1374}
1375
1376function startSplat() {
1377  _splat.closing = false;
1378  if (_splat.closeTimer) { clearTimeout(_splat.closeTimer); _splat.closeTimer = null; }
1379  if (_splat.raf) return;
1380  _splat.items = [];
1381  _splat.lastSpawn = 0;
1382  _splat.nextGap = 0;
1383  _splat.raf = requestAnimationFrame(_splatFrame);
1384}
1385
1386function stopSplat() {
1387  if (_splat.raf) { cancelAnimationFrame(_splat.raf); _splat.raf = null; }
1388  if (_splat.closeTimer) { clearTimeout(_splat.closeTimer); _splat.closeTimer = null; }
1389  _splat.closing = false;
1390  _splat.items = [];
1391  const cv = $("gen-splat-canvas");
1392  if (cv) cv.getContext("2d").clearRect(0, 0, cv.width, cv.height);
1393}
1394
1395// --------------------------------------------------------------------------
1396// Sampling progress overlay
1397//   The Generate button doubles as the progress bar: its label shows the
1398//   current stage (encoding / sampling / decoding / …) and its fill grows
1399//   with the completion fraction. The viewer overlay just runs the splats.
1400// --------------------------------------------------------------------------
1401function showProgress() {
1402  const overlay = $("gen-progress");
1403  overlay.classList.remove("hidden", "fading-out");
1404  updateProgress(null, "Starting…");
1405  startSplat();
1406}
1407
1408function updateProgress(frac, desc) {
1409  if (desc) $("generate-label").textContent = desc;
1410  const fill = $("generate-fill");
1411  if (frac == null || isNaN(frac)) {
1412    fill.style.width = "0%";          // unknown yet → empty
1413  } else {
1414    const clamped = Math.max(0, Math.min(1, frac));
1415    fill.style.width = (clamped * 100).toFixed(1) + "%";
1416  }
1417}
1418
1419function hideProgress() {
1420  $("generate-fill").style.width = "0%";
1421  // Let the splashes fade out naturally instead of vanishing: stop spawning,
1422  // fade the whole overlay via CSS, then tear down once it's invisible.
1423  _splat.closing = true;
1424  const overlay = $("gen-progress");
1425  overlay.classList.add("fading-out");
1426  if (_splat.closeTimer) clearTimeout(_splat.closeTimer);
1427  _splat.closeTimer = setTimeout(() => {
1428    if (!_splat.closing) return;          // a new run started — abort the close
1429    overlay.classList.add("hidden");
1430    overlay.classList.remove("fading-out");
1431    stopSplat();
1432  }, SPLAT_CLOSE_MS);
1433}
1434
1435// --------------------------------------------------------------------------
1436// Display results
1437// --------------------------------------------------------------------------
1438function showResults(result) {
1439  // 1. 3D Viewer — load PLY in the iframe
1440  const plyUrl = result.data[1].url;
1441  const viewerSrc = `/viewer?ply=${encodeURIComponent(plyUrl)}`;
1442  const iframe = $("viewer-iframe");
1443  iframe.src = viewerSrc;
1444  iframe.classList.remove("hidden");
1445  $("viewer-placeholder").classList.add("hidden");
1446  $("viewer-wrap").classList.add("active");
1447
1448  // 2. Parse info string:  "262,144 gaussians  ·  generation: 15.3s  ·  saved: splat.ply"
1449  const info = result.data[3];
1450  const match = info.match(
1451    /^([\d,]+)\s+gaussians\s+·\s+generation:\s+([\d.]+s)\s+·\s+saved:\s+(.+)$/
1452  );
1453  if (match) {
1454    $("out-gaussians").textContent = match[1];
1455    $("out-time").textContent = match[2];
1456    $("out-file").textContent = match[3];
1457  } else {
1458    // Fallback: display the whole string
1459    $("out-gaussians").textContent = info;
1460    $("out-time").textContent = "";
1461    $("out-file").textContent = "";
1462  }
1463
1464  // 3. Download button
1465  const dl = $("download-btn");
1466  dl.href = result.data[2].url;
1467  dl.download = result.data[2].orig_name || "triposplat_output";
1468
1469  // 4. Preprocessed image
1470  $("preprocessed-img").src = result.data[0].url;
1471
1472  // 5. Reveal output section
1473  $("output-placeholder").classList.add("hidden");
1474  const content = $("output-content");
1475  content.classList.remove("hidden");
1476  content.classList.remove("fade-in");
1477  // Force reflow for re-triggering animation
1478  void content.offsetWidth;
1479  content.classList.add("fade-in");
1480}
1481
1482// --------------------------------------------------------------------------
1483// Error handling
1484// --------------------------------------------------------------------------
1485function showError(msg) {
1486  const toast = document.createElement("div");
1487  toast.className = "error-toast fade-in";
1488  toast.textContent = msg;
1489  document.body.appendChild(toast);
1490  setTimeout(() => {
1491    toast.style.opacity = "0";
1492    toast.style.transition = "opacity 0.3s ease";
1493    setTimeout(() => toast.remove(), 350);
1494  }, 5000);
1495}
1496
1497// --------------------------------------------------------------------------
1498// Connection status
1499// --------------------------------------------------------------------------
1500function updateStatus(state, msg) {
1501  const dot = $("status-dot");
1502  const text = $("status-text");
1503
1504  dot.className = "status-dot " + state;
1505  if (state === "connecting") {
1506    text.textContent = "Connecting…";
1507  } else if (state === "connected") {
1508    text.textContent = "Connected";
1509    setTimeout(() => $("status-bar").classList.add("fade-out"), 3000);
1510  } else if (state === "error") {
1511    text.textContent = msg || "Connection error";
1512  }
1513}
1514
1515// --------------------------------------------------------------------------
1516// Boot
1517// --------------------------------------------------------------------------
1518init();
1519</script>
1519
1520</body>
1521</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.