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.