File size: 40,459 Bytes
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f9d2ed
 
 
cebb76b
 
 
 
 
 
 
 
fbbd5ae
cebb76b
fbbd5ae
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191284b
cebb76b
 
 
 
3f9d2ed
 
 
 
 
cebb76b
 
 
 
 
 
 
 
b47ffe0
 
 
 
 
 
 
 
 
 
 
cebb76b
 
 
 
0f856c7
 
 
 
 
 
 
 
 
 
 
 
b82b678
 
 
 
0f856c7
 
 
 
 
 
 
 
 
 
 
cebb76b
 
 
 
3f9d2ed
cebb76b
 
 
3f9d2ed
 
 
cebb76b
3f9d2ed
 
 
 
 
 
 
 
 
 
 
cebb76b
 
 
 
fbbd5ae
 
 
 
 
cebb76b
fbbd5ae
 
 
 
 
 
 
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191284b
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
3f9d2ed
cebb76b
fbbd5ae
cebb76b
3f9d2ed
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
fbbd5ae
cebb76b
fbbd5ae
 
0f856c7
 
 
 
fbbd5ae
0f856c7
 
cebb76b
 
 
 
 
 
 
191284b
0f856c7
cebb76b
3f9d2ed
 
 
 
 
 
cebb76b
 
 
 
 
 
 
 
 
 
0f856c7
cebb76b
 
 
 
 
0f856c7
fbbd5ae
 
 
 
 
 
 
 
 
 
 
 
cebb76b
 
 
 
 
0f856c7
cebb76b
 
 
 
0f856c7
cebb76b
 
 
 
 
 
 
79fbc32
 
 
77b1eb5
 
 
79fbc32
77b1eb5
79fbc32
 
 
 
 
 
 
fbbd5ae
 
 
77b1eb5
cebb76b
77b1eb5
fbbd5ae
cebb76b
 
 
 
77b1eb5
 
 
 
 
 
 
 
 
cebb76b
 
0f856c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cebb76b
 
 
fbbd5ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cebb76b
 
77b1eb5
 
fbbd5ae
 
cebb76b
77b1eb5
cebb76b
 
 
 
fbbd5ae
 
 
 
 
 
b47ffe0
cebb76b
fbbd5ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cebb76b
fbbd5ae
 
 
 
 
 
 
b47ffe0
fbbd5ae
 
 
 
 
 
 
b47ffe0
 
 
 
 
 
 
 
fbbd5ae
b47ffe0
cebb76b
fbbd5ae
cebb76b
fbbd5ae
 
b47ffe0
fbbd5ae
 
 
 
 
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77b1eb5
 
cebb76b
77b1eb5
cebb76b
 
fbbd5ae
cebb76b
fbbd5ae
0f856c7
 
 
 
fbbd5ae
 
 
 
 
0f856c7
 
cebb76b
fbbd5ae
 
77b1eb5
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbbd5ae
cebb76b
fbbd5ae
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbbd5ae
cebb76b
fbbd5ae
cebb76b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77b1eb5
 
 
 
0f856c7
 
 
 
 
fa04a87
 
 
 
 
 
 
 
 
 
 
 
 
 
cebb76b
 
fa04a87
 
 
c5718ef
fa04a87
 
 
 
 
 
 
 
 
 
 
 
b47ffe0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fbbd5ae
 
 
 
 
 
 
 
77b1eb5
 
 
fa04a87
77b1eb5
3f9d2ed
77b1eb5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cebb76b
0f856c7
cebb76b
 
 
 
 
77b1eb5
 
cebb76b
b47ffe0
 
 
 
 
cebb76b
 
 
0f856c7
 
 
 
 
 
 
 
 
 
 
 
 
77b1eb5
 
 
 
 
 
cebb76b
 
b47ffe0
 
 
 
 
 
 
77b1eb5
 
fbbd5ae
77b1eb5
cebb76b
 
77b1eb5
cebb76b
77b1eb5
cebb76b
 
0f856c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cebb76b
 
 
 
 
 
62a0c6b
 
 
 
 
 
cebb76b
62a0c6b
 
fbbd5ae
 
 
 
62a0c6b
 
 
 
 
77b1eb5
62a0c6b
 
77b1eb5
 
 
fbbd5ae
 
 
 
 
cebb76b
 
 
 
 
4d4c93b
 
 
 
 
 
 
 
cebb76b
62a0c6b
77b1eb5
cebb76b
 
62a0c6b
 
 
77b1eb5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
"""TTB-DVSM HF Spaces (ZeroGPU) demo.

Upload images / a short orbit video -> π³ pose estimation + TTB-DVSM
reconstruction (one GPU call) -> a *time-boxed* interactive viser viewer that
holds the GPU for the duration of one `@spaces.GPU` session.

Why this shape (ZeroGPU constraints, see README):
  - ZeroGPU forks a fresh worker process for each `@spaces.GPU` call and
    releases the GPU when the call returns; GPUs cannot be held permanently.
  - A generator decorated with `@spaces.GPU` keeps the GPU for as long as it is
    being iterated. We exploit that: `_live_session_worker` is a generator that
    starts a viser server *inside the GPU worker* and stays alive (yielding
    heartbeats) until its `duration` budget runs out. The main process reverse-
    proxies `/viser-proxy/*` to that worker-local viser port so the viewer is
    reachable from the single public Gradio port.
  - When the session expires the worker dies (GPU released). The user clicks
    "Start / Continue" to fork a new worker and rebuild the viewer from the
    already-computed artifact on disk (no re-reconstruction).

Models (nvidia/DVSM): uploads are reconstructed with DVSM-TTB-p8-general-960p; the
gallery examples were baked with DVSM-TTB-p8-DL3DV-960p. A live session renders an
artifact with the model that baked it.
"""
from __future__ import annotations

import os
import shutil
import socket
import sys
import tempfile
import threading
import time
import uuid
from pathlib import Path

import numpy as np

PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))

# ZeroGPU. `spaces` is a no-op-ish shim off-platform; provide a fallback so the
# module is importable for local testing without the package.
try:
    import spaces
except Exception:  # pragma: no cover - local dev without `spaces`
    class _SpacesShim:
        def GPU(self, *args, **kwargs):
            # Support both @spaces.GPU and @spaces.GPU(duration=...)
            if args and callable(args[0]):
                return args[0]
            def _wrap(fn):
                return fn
            return _wrap
    spaces = _SpacesShim()

import gradio as gr

from viser_app.scene_loaders import (
    IMAGE_EXTS, VIDEO_EXTS, extract_video_frames,
)
from viser_app.zerogpu_compat import repair_torch_cuda_state

# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
# Uploaded images / videos are reconstructed with the general model. The gallery
# examples were baked with the DL3DV model. An artifact records the variant that
# baked it ("hf_variant") and has to be rendered with that same model.
UPLOAD_VARIANT = "DVSM-TTB-p8-general-960p"
EXAMPLE_VARIANT = "DVSM-TTB-p8-DL3DV-960p"
# How long one held-GPU interactive session lasts before it must be renewed.
# Bounded by the per-call duration cap and the user's daily quota
# (anon 2min / free 5min / PRO 40min). Override via env for PRO accounts.
SESSION_SECONDS = int(os.environ.get("SESSION_SECONDS", "120"))
RECON_DURATION = int(os.environ.get("RECON_DURATION", "120"))
# Reduced benchmark width set keeps per-session warmup short (every session
# re-warms from scratch — CUDA graphs can't persist across GPU workers).
BENCH_WIDTHS = [int(x) for x in os.environ.get("BENCH_WIDTHS", "512 768").split()]
# Upload budget: frames x tokens per frame may not exceed 64 views at 960 px
# (960x544 at patch 8 = 120x68 tokens per view). The two sliders trade one for
# the other: the one being moved wins and the other gives way (_FIT_*_JS), using
# the aspect-free form frames * width^2 <= 64 * 960^2. reconstruct_cb then checks
# the real token count, because an input taller than 16:9 has more tokens per
# frame at the same long axis.
PATCH = 8
TOKEN_BUDGET = 64 * (960 // PATCH) * (544 // PATCH)
FRAMES_MIN, FRAMES_MAX, FRAMES_STEP = 8, 128, 4
WIDTH_MIN, WIDTH_MAX, WIDTH_STEP = 384, 960, 32
_SLIDER_BUDGET = 64 * 960 ** 2

ARTIFACTS_DIR = Path(tempfile.gettempdir()) / "dvsm_artifacts"
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)

# Pre-built example scenes are auto-discovered from examples/: every
# "<stem>.recon.pt" (baked with THIS model) that has a matching "<stem>.jpg|png"
# thumbnail becomes a gallery entry. Drop files in / remove them to change the
# gallery — no code edit needed. Clicking a thumbnail skips reconstruction and
# opens the live session directly on the artifact.
EXAMPLES_DIR = PROJECT_ROOT / "examples"


def _discover_examples():
    """Every "<stem>.recon.pt" with a matching "<stem>.jpg|jpeg|png|webp"
    thumbnail. The gallery shows thumbnails only (no labels)."""
    scenes = []
    # Gallery order: Mip-NeRF 360, then DL3DV, then our own captures.
    order = ("mipnerf360_", "dl3dv_")
    rank = lambda p: next((i for i, k in enumerate(order) if p.name.startswith(k)), len(order))
    for recon in sorted(EXAMPLES_DIR.glob("*.recon.pt"), key=lambda p: (rank(p), p.name)):
        stem = recon.name[: -len(".recon.pt")]
        thumb = next((EXAMPLES_DIR / f"{stem}{ext}"
                      for ext in (".jpg", ".jpeg", ".png", ".webp")
                      if (EXAMPLES_DIR / f"{stem}{ext}").exists()), None)
        if thumb is not None:
            scenes.append({"thumb": thumb, "recon": recon})
    return scenes


EXAMPLE_SCENES = _discover_examples()

# ---------------------------------------------------------------------------
# Module-level model load: download + keep on CPU (ZeroGPU convention).
# Real `.cuda()` happens inside @spaces.GPU workers.
# ---------------------------------------------------------------------------
print(f"[app] loading {UPLOAD_VARIANT}, {EXAMPLE_VARIANT} + π³ to CPU ...", flush=True)
from dvsm.models.DVSM import DecoderViewSynthesisModel
from viser_app.pi3_estimator import load_pi3_model

# APP_MODEL_DIR: load the DVSM models from a local directory instead of the Hub
# (faster / offline; used for local GPU-node testing). It holds one HF-format
# sub-directory per variant, i.e. the layout of the nvidia/DVSM repo.
_LOCAL_MODEL_DIR = os.environ.get("APP_MODEL_DIR")


def _load_dvsm(variant):
    if _LOCAL_MODEL_DIR:
        path = Path(_LOCAL_MODEL_DIR) / variant
        print(f"[app] loading {variant} from local dir {path}", flush=True)
        return DecoderViewSynthesisModel.from_pretrained(str(path)).eval()
    return DecoderViewSynthesisModel.from_hf_pretrained(variant).eval()


DVSM_MODELS_CPU = {v: _load_dvsm(v) for v in (UPLOAD_VARIANT, EXAMPLE_VARIANT)}
PI3_MODEL_CPU = load_pi3_model(flavor="pi3x", device="cpu").eval()
print("[app] models ready (CPU)", flush=True)

# ---------------------------------------------------------------------------
# Per-session viser registry (token -> worker viser port). The Gradio Space is
# a SINGLE shared process for ALL users — ZeroGPU only isolates GPU compute per
# @spaces.GPU call, NOT the app/server/state. So proxy routing MUST be keyed by
# a per-session token; a single global port would make every user's iframe
# point at whichever session set it last (they'd all collide on one viser).
# ---------------------------------------------------------------------------
_sessions: dict = {}              # token -> viser port (present == alive)
_sessions_lock = threading.Lock()


def _session_port(token: str):
    with _sessions_lock:
        return _sessions.get(token)


def _pick_free_port() -> int:
    s = socket.socket()
    s.bind(("", 0))
    port = s.getsockname()[1]
    s.close()
    return port


# ---------------------------------------------------------------------------
# GPU boundary (a): reconstruction
# ---------------------------------------------------------------------------
@spaces.GPU(duration=RECON_DURATION)
def reconstruct_gpu(img_dir: str, target_h: int, target_w: int, max_views: int) -> str:
    """π³ pose estimation + TTB-DVSM forward -> saved .recon.pt. Runs in a GPU
    worker; returns the artifact path (on shared /tmp) for the session call."""
    import torch
    from viser_app.pi3_estimator import _list_images, pi3_to_scene_data, run_pi3
    from viser_app.recon_scene import reconstruct, save_artifact

    repair_torch_cuda_state()  # torch.compile vs ZeroGPU, see zerogpu_compat
    image_paths = _list_images(Path(img_dir))
    if max_views and len(image_paths) > max_views:
        idxs = np.linspace(0, len(image_paths) - 1, max_views).round().astype(int)
        image_paths = [image_paths[i] for i in idxs]

    pi3 = PI3_MODEL_CPU.cuda()
    est = run_pi3(pi3, image_paths, image_size=518)
    del pi3
    torch.cuda.empty_cache()
    scene_data = pi3_to_scene_data(est, target_h, target_w)

    # Reuse the module-level CPU model (moved to cuda inside reconstruct()).
    renderer, batch, ishape, c2w_r, c2w_t, intr = reconstruct(
        ckpt_root=None, ckpt_name="", scene_data=scene_data, cmd_lst=[],
        hf_variant=UPLOAD_VARIANT, model=DVSM_MODELS_CPU[UPLOAD_VARIANT],
    )
    out_path = ARTIFACTS_DIR / f"{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.recon.pt"
    save_artifact(renderer, batch, ishape, c2w_r, c2w_t, intr, out_path,
                  hf_variant=UPLOAD_VARIANT)
    return str(out_path)


# ---------------------------------------------------------------------------
# GPU boundary (b): time-boxed interactive viser session (generator)
# ---------------------------------------------------------------------------
class _SessionArgs:
    """Mimics the argparse namespace install_viser_handlers expects."""
    max_res = 768
    no_async_push = False
    bench_widths = BENCH_WIDTHS


@spaces.GPU(duration=SESSION_SECONDS)
def _live_session_worker(artifact_path: str):
    """Generator running in the GPU worker. Builds the renderer, starts a viser
    server on a self-chosen free port, then holds the GPU until the session
    budget expires.

    Yields tagged tuples so the UI can show a prep progress bar then a live
    countdown:
        ("prep",  frac, desc)     during setup
        ("ready", port, None)     viser is up and serving on `port`
        ("alive", remaining, None) per-second heartbeat while holding the GPU
    """
    import torch
    import viser
    from dvsm.models.pose_preprocessor import PosePreprocessor
    from viser_app.render import build_renderer
    from viser_app.viewer import install_viser_handlers
    from viser_app.viewer_optim import install_optimizations

    repair_torch_cuda_state()  # torch.compile vs ZeroGPU, see zerogpu_compat
    yield ("prep", 0.15, "Loading scene…")
    artifact = torch.load(artifact_path, weights_only=False, map_location="cpu")
    # Render with the model that baked this artifact (uploads vs gallery examples).
    variant = artifact.get("hf_variant") or EXAMPLE_VARIANT
    if variant not in DVSM_MODELS_CPU:
        raise RuntimeError(f"artifact was baked with {variant}, which this app does not load")
    print(f"[session] rendering with {variant}", flush=True)
    model = DVSM_MODELS_CPU[variant].cuda().eval()
    renderer = build_renderer(model, artifact)
    scene_meta = artifact["scene_meta"]

    pp_cpu = PosePreprocessor.__new__(PosePreprocessor)
    pp_cpu.r = artifact["pose_preproc"]["r"].cpu()
    pp_cpu.t = artifact["pose_preproc"]["t"].cpu()
    pp_cpu.s = artifact["pose_preproc"]["s"].cpu()
    renderer.pose_preprocessor = None
    pp_scale = float(pp_cpu.s.reshape(-1)[0])

    yield ("prep", 0.4, "Loading scene…")
    renderer = install_optimizations(
        renderer, use_fast_plucker=True, use_cuda_graph=True,
        n_warmup=5, max_graphs=len(BENCH_WIDTHS) + 2,
    )

    yield ("prep", 0.6, "Loading scene…")
    # Pick + bind the port HERE (inside the worker), with retry, so concurrent
    # sessions can't race on the same port (no cross-process TOCTOU).
    server = None
    for _ in range(10):
        port = _pick_free_port()
        try:
            server = viser.ViserServer(host="0.0.0.0", port=port)
            break
        except Exception:
            server = None
    if server is None:
        raise RuntimeError("could not bind a free viser port")
    server.scene.set_up_direction("+y")
    install_viser_handlers(server, renderer, scene_meta, pp_cpu, pp_scale,
                           model, _SessionArgs())

    deadline = time.monotonic() + SESSION_SECONDS - 5  # margin before hard kill
    yield ("ready", port, None)
    while True:
        remaining = int(deadline - time.monotonic())
        if remaining <= 0:
            break
        yield ("alive", remaining, None)
        time.sleep(1.0)
    try:
        server.stop()
    except Exception:
        pass


def _placeholder_html(msg: str = "Viser Viewer") -> str:
    """Same-size 16:9 placeholder shown before the viser iframe loads, so the
    layout doesn't jump and the user knows where the viewer will be."""
    # No own border/background — the gr.HTML(container=True) .block already
    # provides the single frame + background. Just a 16:9 centered hint, theme-
    # aware text; the ◧ accent stays NVIDIA green in light/dark.
    return (
        "<div style='width:100%;aspect-ratio:16/9;overflow:hidden;display:flex;"
        "flex-direction:column;align-items:center;justify-content:center;"
        "color:var(--body-text-color-subdued);text-align:center;gap:8px'>"
        "<div style='font-size:2.4em;line-height:1;color:#76B900'>◧</div>"
        f"<div style='font-size:1.05em;font-weight:600'>{msg}</div></div>"
    )


def _iframe_html(token: str) -> str:
    # Token in the PATH so every viser sub-request (assets + websocket) inherits
    # it and the proxy can route this user's traffic to their own worker.
    # No own border — the gr.HTML(container=True) .block frames it.
    return (
        f"<div style='width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden'>"
        f"<iframe src='/viser-proxy/{token}/' style='width:100%;height:100%;border:0' "
        f"allow='clipboard-write'></iframe></div>"
    )


def _viewer_message(msg: str, color: str = "var(--body-text-color-subdued)") -> str:
    """A status message centered inside the same 16:9 viewer box, so recon
    progress / results overlay the placeholder area (aligned with the upload
    box on the left) instead of appearing below the viewer."""
    return (
        "<div style='width:100%;aspect-ratio:16/9;overflow:hidden;display:flex;"
        "align-items:center;justify-content:center;text-align:center;padding:0 1em;"
        f"color:{color};font-weight:600;font-size:1.05em'>{msg}</div>"
    )


def _prep_overlay(frac: float, desc: str) -> str:
    """Determinate green progress bar centered in the viewer box, shown while
    the viser session is being prepared (renderer build + warmup + bench)."""
    pct = max(0, min(100, round(100 * frac)))
    return (
        "<div style='width:100%;aspect-ratio:16/9;overflow:hidden;display:flex;"
        "flex-direction:column;align-items:center;justify-content:center;gap:10px;"
        "padding:0 12%;color:var(--body-text-color-subdued)'>"
        f"<div style='font-weight:600;font-size:1.05em'>{desc}</div>"
        "<div style='width:100%;height:8px;background:#333;border-radius:4px;overflow:hidden'>"
        f"<div style='height:100%;width:{pct}%;background:#76B900;"
        "transition:width .3s linear'></div></div></div>"
    )


# ---------------------------------------------------------------------------
# Gradio callbacks (main process)
# ---------------------------------------------------------------------------
def _delete_artifact(path):
    """Remove a user-generated recon artifact. Used both as the gr.State
    delete_callback (fires when the session ends / TTL expires) and to drop the
    previous artifact when a user reconstructs again. Guarded to ARTIFACTS_DIR so
    it never touches the permanent examples/ files."""
    try:
        if path and isinstance(path, str):
            p = Path(path)
            if p.parent == ARTIFACTS_DIR and p.exists():
                p.unlink()
    except Exception:
        pass


def reconstruct_cb(input_files, n_frames, target_w, prev_artifact,
                   progress=gr.Progress(track_tqdm=False)):
    """CPU pre-processing (decode video / copy images / size) then one GPU
    reconstruction call. Returns (artifact_path, viewer_html, start_btn_update).
    The status overlays the viewer box (see _viewer_message)."""
    # Drop this session's previous artifact (if any) before making a new one.
    _delete_artifact(prev_artifact)
    if not input_files:
        return "", _viewer_message("Upload a video or at least 3 images.", "#c66"), gr.update(interactive=False)

    work = Path(tempfile.mkdtemp(prefix="dvsm_in_"))
    img_dir = work / "images"
    img_dir.mkdir()
    # The uploaded/extracted input frames are only needed during reconstruction;
    # delete the whole work dir on exit so /tmp doesn't accumulate per request.
    try:
        paths = [Path(f.name if hasattr(f, "name") else f) for f in input_files]
        videos = [p for p in paths if p.suffix.lower() in VIDEO_EXTS]
        images = [p for p in paths if p.suffix.lower() in IMAGE_EXTS]
        n_frames = min(max(int(n_frames), 3), FRAMES_MAX)

        if videos:
            progress(0.05, desc=f"Extracting {n_frames} frames from video...")
            try:
                n = extract_video_frames(videos[0], img_dir, n_frames)
            except Exception as e:
                return "", _viewer_message(f"Video decode failed: {e}", "#c66"), gr.update(interactive=False)
            if n < 3:
                return "", _viewer_message(f"Only {n} frames extracted; need ≥3.", "#c66"), gr.update(interactive=False)
            n_inputs = n
        elif len(images) >= 3:
            if len(images) > n_frames:
                idxs = np.linspace(0, len(images) - 1, n_frames).round().astype(int)
                images = [images[i] for i in idxs]
            progress(0.05, desc=f"Copying {len(images)} files...")
            for src in images:
                shutil.copy(src, img_dir / src.name)
            n_inputs = len(images)
        else:
            return "", _viewer_message("Upload a video or at least 3 images.", "#c66"), gr.update(interactive=False)

        # Snap target dims to the input aspect; slider is the long axis; multiples of 8.
        try:
            from PIL import Image as _PILImage
            first_img = next(iter(sorted(img_dir.iterdir())))
            with _PILImage.open(first_img) as im:
                iw, ih = im.size
            input_aspect = iw / ih
        except Exception:
            input_aspect = 16 / 9
        long_axis = (min(int(target_w), WIDTH_MAX) // 8) * 8
        if input_aspect >= 1.0:
            target_w, target_h = long_axis, int(round(long_axis / input_aspect))
        else:
            target_h, target_w = long_axis, int(round(long_axis * input_aspect))
        target_h = max(8, (target_h // 8) * 8)
        target_w = max(8, (target_w // 8) * 8)

        # Token budget, on the real size. The sliders already keep a 16:9 input
        # inside it; a taller aspect gets fewer (evenly spaced) frames.
        tokens_per_view = (target_h // PATCH) * (target_w // PATCH)
        n_views = max(3, min(n_inputs, TOKEN_BUDGET // tokens_per_view))
        trimmed = (f" Used {n_views} of {n_inputs} frames to stay within the token budget."
                   if n_views < n_inputs else "")

        progress(0.2, desc=f"Running π³ + TTB-DVSM ({n_views} frames)...")
        try:
            artifact_path = reconstruct_gpu(str(img_dir), target_h, target_w, int(n_views))
        except Exception as e:
            return "", _viewer_message(f"Reconstruction failed: {e}", "#c66"), gr.update(interactive=False)

        return (
            artifact_path,
            _viewer_message(f"Reconstruction done ({n_views} views, {target_w}×{target_h}).{trimmed} "
                    f"Click “Start / Continue Live Session”."),
            gr.update(interactive=True),
        )
    finally:
        shutil.rmtree(work, ignore_errors=True)


def _session_bar(remaining: int, total: int, expired: bool = False) -> str:
    """Our own animated green countdown bar (one bar, rendered below the viewer).
    We draw it ourselves rather than relying on Gradio's auto progress, which
    would put a bar on every output component of the streaming event."""
    if expired:
        return (
            "<div style='margin-top:4px'>"
            "<div style='height:8px;background:#333;border-radius:4px'></div>"
            "<div style='font-weight:600;color:#c89b3c;margin-top:5px'>Session expired "
            "(GPU released). Click “Start / Continue Live Session” to resume.</div></div>"
        )
    pct = max(0, min(100, round(100 * remaining / max(1, total))))
    return (
        "<div style='margin-top:4px'>"
        "<div style='height:8px;background:#333;border-radius:4px;overflow:hidden'>"
        f"<div style='height:100%;width:{pct}%;background:#76B900;"
        "transition:width 1s linear'></div></div>"
        f"<div style='font-weight:600;color:#76B900;margin-top:5px'>Live — ~{remaining}s "
        "of GPU time left. Drag to explore.</div></div>"
    )


def live_session(artifact_path):
    """Gradio generator handler. Drives `_live_session_worker` (which holds the
    GPU) and streams the iframe + green countdown bar to the UI.

    Outputs: (viewer_html, session_status). The first yield swaps the recon
    message in the viewer box for the iframe; the countdown bar lives below."""
    if not artifact_path:
        yield _viewer_message("Reconstruct a scene first.", "#c66"), gr.update()
        return

    token = uuid.uuid4().hex
    try:
        for tag, a, _b in _live_session_worker(artifact_path):
            if tag == "prep":
                # a = fraction, _b = description -> prep bar overlaid in the viewer
                yield (_prep_overlay(a, _b), gr.update())
            elif tag == "ready":
                # viser is up on port `a` -> register this token's route, then
                # swap in the iframe (which carries the token).
                with _sessions_lock:
                    _sessions[token] = int(a)
                yield (_iframe_html(token), _session_bar(SESSION_SECONDS, SESSION_SECONDS))
            else:  # "alive": a = seconds remaining
                yield (gr.update(), _session_bar(int(a), SESSION_SECONDS))
    finally:
        with _sessions_lock:
            _sessions.pop(token, None)
    yield (gr.update(), _session_bar(0, SESSION_SECONDS, expired=True))


# ---------------------------------------------------------------------------
# Reverse proxy: /viser-proxy/* -> worker-local viser port
# ---------------------------------------------------------------------------
def _build_proxy_handlers():
    import asyncio
    import httpx
    import websockets as ws_pkg
    from starlette.requests import Request
    from starlette.responses import Response
    from starlette.websockets import WebSocket

    http_client = httpx.AsyncClient(timeout=60.0)

    async def proxy_http(request: Request) -> Response:
        token = request.path_params.get("token", "")
        path = request.path_params.get("path", "")
        port = _session_port(token)
        if port is None:
            return Response(b"No active viser session.", status_code=503)
        url = f"http://localhost:{port}/{path}"
        if request.url.query:
            url += "?" + request.url.query
        headers = {k: v for k, v in request.headers.items()
                   if k.lower() not in ("host", "content-length")}
        body = await request.body()
        try:
            r = await http_client.request(request.method, url, headers=headers,
                                          content=body, follow_redirects=False)
        except Exception as e:
            return Response(f"viser unreachable: {e}".encode(), status_code=502)
        resp_headers = {k: v for k, v in r.headers.items()
                        if k.lower() not in ("content-encoding", "transfer-encoding",
                                             "content-length", "connection")}
        return Response(content=r.content, status_code=r.status_code, headers=resp_headers)

    async def proxy_ws(websocket: WebSocket) -> None:
        token = websocket.path_params.get("token", "")
        path = websocket.path_params.get("path", "")
        port = _session_port(token)
        offered = list(websocket.scope.get("subprotocols") or [])
        if port is None:
            await websocket.close(code=1011)
            return
        try:
            upstream = await ws_pkg.connect(
                f"ws://localhost:{port}/{path}", subprotocols=offered or None,
                max_size=50 * 1024 * 1024, compression=None,
            )
        except Exception:
            await websocket.close(code=1011)
            return
        await websocket.accept(subprotocol=upstream.subprotocol)

        async def c2u():
            try:
                while True:
                    msg = await websocket.receive()
                    if msg.get("type") == "websocket.disconnect":
                        return
                    b, t = msg.get("bytes"), msg.get("text")
                    if b is not None:
                        await upstream.send(b)
                    elif t is not None:
                        await upstream.send(t)
            except Exception:
                pass
            finally:
                try:
                    await upstream.close()
                except Exception:
                    pass

        async def u2c():
            try:
                async for msg in upstream:
                    if isinstance(msg, (bytes, bytearray)):
                        await websocket.send_bytes(bytes(msg))
                    else:
                        await websocket.send_text(msg)
            except Exception:
                pass
            finally:
                try:
                    await websocket.close()
                except Exception:
                    pass

        await asyncio.gather(c2u(), u2c())

    return proxy_http, proxy_ws


# ---------------------------------------------------------------------------
# UI
# ---------------------------------------------------------------------------
NVIDIA_GREEN = "#76B900"
NVIDIA_GREEN_DARK = "#5C8F00"
_PALETTE = gr.themes.Color(
    name="nvidia_green", c50="#F0F8E0", c100="#E1F0C2", c200="#C7E690",
    c300="#A8D958", c400="#8BC53F", c500=NVIDIA_GREEN, c600=NVIDIA_GREEN_DARK,
    c700="#4A7300", c800="#395800", c900="#283D00", c950="#1A2900",
)
_theme = gr.themes.Default(primary_hue=_PALETTE).set(
    button_primary_background_fill=NVIDIA_GREEN,
    button_primary_background_fill_hover=NVIDIA_GREEN_DARK,
    button_primary_text_color="white",
    slider_color=NVIDIA_GREEN,
)
# Gradio 6 ignores Blocks(theme=...) when the app is served via
# mount_gradio_app (theme moved to launch()). Force NVIDIA green with CSS so the
# palette applies regardless of how the app is launched.
_custom_css = """
button.primary, button[variant='primary'], .gr-button-primary {
    background: #76B900 !important; background-image: none !important;
    border-color: #76B900 !important; box-shadow: none !important; color: #fff !important;
}
button.primary:hover, button[variant='primary']:hover, .gr-button-primary:hover {
    background: #5C8F00 !important; background-image: none !important; border-color: #5C8F00 !important;
}
input[type='range']::-webkit-slider-thumb { background: #76B900 !important; }
input[type='range']::-moz-range-thumb     { background: #76B900 !important; }
.gr-slider .filled, .slider .filled       { background: #76B900 !important; }
*:focus-visible, *:focus { outline-color: #76B900 !important; }
.gradio-container input:focus, .gradio-container textarea:focus, .gradio-container select:focus {
    border-color: #76B900 !important; box-shadow: 0 0 0 2px rgba(118, 185, 0, 0.4) !important;
}
.gallery .selected, .gr-gallery .selected,
.gallery .thumbnail:hover, .gr-gallery .thumbnail:hover { border-color: #76B900 !important; }
.progress, .gr-loader, .loader { background: #76B900 !important; }
.tab-nav .selected, .gr-tab-nav .selected { border-bottom-color: #76B900 !important; }
/* Flush blocks: zero the .block inner padding so the upload box and the viewer
   sit flush at the column top (kept aligned) and the viewer fills its frame
   edge-to-edge (no padding gap around the iframe). */
.flush-block { padding: 0 !important; }
/* Proportional columns: flex-basis 0 so the row width is always split 1:3 and
   BOTH columns scale with the window (default Gradio scale only distributes the
   leftover space, leaving the left column looking fixed). */
.col-left  { flex: 1 1 0 !important; min-width: 260px; }
.col-right { flex: 3 1 0 !important; min-width: 360px; }
/* Header text links under the title (see _header_links): a quiet grey line,
   brighter and underlined on hover. Overrides what Gradio gives every <a> in an
   HTML block (button-like padding, centred text, its own link colour). The
   light theme gets darker greys so the line stays readable on white. */
.ttb-links { display: flex; flex-wrap: wrap; gap: 0 0.6em; font-size: 1em; line-height: 1.6; margin-top: 0.15em; }
.ttb-links a {
    padding: 0 !important; text-align: left !important; font-weight: 400;
    color: var(--neutral-500) !important; text-decoration: none !important;
}
.dark .ttb-links a { color: var(--body-text-color-subdued) !important; }
.ttb-links a:hover { color: var(--body-text-color) !important; text-decoration: underline !important; }
.dark .ttb-links a:hover { color: var(--body-text-color) !important; }
.ttb-links span { color: var(--neutral-300) !important; }
.dark .ttb-links span { color: var(--border-color-primary) !important; }
"""

# Paper / project page / code links shown as a line of text links under the
# title. A link with an empty URL is left out (the arXiv link, until the paper
# has its arXiv page).
ARXIV_URL = "https://arxiv.org/abs/2611.17331"
PROJECT_URL = "https://test-time-baking.github.io/"
CODE_URL = "https://github.com/NVlabs/DVSM"


def _header_links() -> str:
    links = [("arXiv", ARXIV_URL), ("Project Page", PROJECT_URL), ("Code", CODE_URL)]
    # target=_blank: the Space runs in an iframe on huggingface.co.
    return "<div class='ttb-links'>" + "<span>/</span>".join(
        f"<a href='{url}' target='_blank' rel='noopener noreferrer'>{label}</a>"
        for label, url in links if url) + "</div>"


# Slider coupling (frames, width) -> the largest value of the OTHER slider that
# still fits frames * width^2 <= _SLIDER_BUDGET, never raising it.
_FIT_WIDTH_JS = f"""(n, w) => {{
    if (n * w * w <= {_SLIDER_BUDGET}) return [w];
    let m = {WIDTH_MAX};
    while (m > {WIDTH_MIN} && n * m * m > {_SLIDER_BUDGET}) m -= {WIDTH_STEP};
    return [Math.min(w, m)];
}}"""
_FIT_FRAMES_JS = f"""(n, w) => {{
    if (n * w * w <= {_SLIDER_BUDGET}) return [n];
    let m = {FRAMES_MAX};
    while (m > {FRAMES_MIN} && m * w * w > {_SLIDER_BUDGET}) m -= {FRAMES_STEP};
    return [Math.min(n, m)];
}}"""

# delete_cache=(freq, age): Gradio sweeps its OWN cache (uploaded files etc.)
# every hour, removing entries older than an hour — a safety net against disk
# exhaustion on HF Spaces.
with gr.Blocks(title="TTB-DVSM (ZeroGPU)", delete_cache=(3600, 3600)) as demo:
    # delete_callback ties each user's recon artifact lifetime to their session:
    # Gradio calls it when the State is cleaned up (tab closed +60min, or TTL),
    # so /tmp artifacts don't accumulate. time_to_live also bounds idle sessions.
    artifact_state = gr.State("", delete_callback=_delete_artifact, time_to_live=3600)
    # Full-width description on top.
    gr.HTML(
        "<h1 style='margin-bottom:0.1em;color:#76B900'>TTB-DVSM</h1>"
        + _header_links() +
        "<p style='color:var(--body-text-color);font-size:1.1em;margin-top:0.5em;line-height:1.6'>"
        "TTB bakes the scene into fast weights in a single forward pass, "
        "and the result is browseable in the viser viewer."
        "</p>"
        "<div style='color:var(--body-text-color);font-size:1.1em;margin-top:0.8em;line-height:1.7'>"
        "<div style='color:#76B900;font-weight:700;font-size:1.15em;margin-bottom:0.3em'>"
        "Getting Started</div>"
        "<b>1.</b> Drop 16–128 photos <i>or</i> a short orbit video into the input box.<br>"
        "<b>2.</b> Pick how many frames to use and the reconstruction width, "
        "then click <b>Reconstruct</b>.<br>"
        "<b>3.</b> Click <b>Start / Continue Live Session</b> to open the "
        "interactive viewer.<br>"
        "<i>ZeroGPU holds the GPU only for a bounded session; when it expires, "
        "click Continue to resume.</i>"
        "<div style='color:#76B900;font-weight:700;font-size:1.15em;margin:0.7em 0 0.3em'>"
        "Viewer controls</div>"
        "&nbsp;• <b>Left-drag</b> orbit &nbsp;&nbsp;"
        "<b>Right-drag</b> pan &nbsp;&nbsp;"
        "<b>Scroll</b> zoom<br>"
        "&nbsp;• <b>W A S D</b> fly through the scene &nbsp;&nbsp;"
        "<b>Q / E</b> down / up &nbsp;&nbsp;"
        "<b>Shift</b> faster<br>"
        "&nbsp;• Right-side panel: <b>Max W</b>, "
        "<b>Jump to input view</b>, <b>Reset View</b>."
        "</div>"
    )
    # Below the text: 1 (inputs + buttons) : 3 (viser viewer).
    with gr.Row():
        with gr.Column(scale=1, min_width=260, elem_classes=["col-left"]):
            inputs = gr.File(
                file_count="multiple",
                file_types=["image", "video", ".mp4", ".mov", ".mkv",
                            ".webm", ".avi", ".m4v"],
                label="Drop photos OR a short video (orbiting one scene)",
                show_label=False,
                elem_classes=["flush-block"],
            )
            n_frames = gr.Slider(FRAMES_MIN, FRAMES_MAX, 32, step=FRAMES_STEP,
                                 label="Frames to sample")
            tw = gr.Slider(WIDTH_MIN, WIDTH_MAX, 768, step=WIDTH_STEP,
                           label="Reconstruction width (long axis)",
                           info="Budget: 64 frames at 960 px. More frames need a lower width.")
            recon_btn = gr.Button("Reconstruct", variant="primary")
            session_btn = gr.Button("Start / Continue Live Session",
                                    variant="primary", interactive=False)
            if EXAMPLE_SCENES:
                gr.Markdown("### Example")
                example_gallery = gr.Gallery(
                    # No captions. Fixed box height -> vertical scrollbar when
                    # there are more thumbnails than fit; width still scales with
                    # the (resizable) column since it's 3 columns.
                    value=[str(s["thumb"]) for s in EXAMPLE_SCENES],
                    columns=3, height=200, object_fit="cover",
                    show_label=False, allow_preview=False,
                )
            else:
                example_gallery = None
        with gr.Column(scale=3, elem_classes=["col-right"]):
            # Viewer first so its top edge aligns with the upload box on the
            # left (both flush via .flush-block). Recon progress/status overlay
            # the viewer box itself (see reconstruct_cb); only the live-session
            # countdown bar lives BELOW the viewer.
            viewer_html = gr.HTML(_placeholder_html(), container=True,
                                  elem_classes=["flush-block"])
            session_status = gr.HTML()

    # The two sliders share one budget (see TOKEN_BUDGET). Client-side only: the
    # slider that changed keeps its value and the other is lowered just enough to
    # fit. `change` rather than `input` so the reset buttons are covered too; the
    # lowered slider's own change event is a no-op because the pair then fits.
    n_frames.change(None, [n_frames, tw], [tw], js=_FIT_WIDTH_JS)
    tw.change(None, [n_frames, tw], [n_frames], js=_FIT_FRAMES_JS)

    # Recon status overlays the viewer box; the gradio progress bar (the "20%")
    # therefore also shows over the viewer, aligned with the upload column.
    recon_btn.click(reconstruct_cb, [inputs, n_frames, tw, artifact_state],
                    [artifact_state, viewer_html, session_btn])
    # show_progress="hidden": the session generator streams for the whole GPU
    # hold; without this Gradio overlays an auto progress bar on EACH output
    # component. We draw our own green countdown bar instead.
    session_btn.click(live_session, [artifact_state],
                      [viewer_html, session_status],
                      show_progress="hidden")

    # Clicking an example thumbnail loads its prebuilt artifact and opens the
    # live session directly (no reconstruction).
    if example_gallery is not None:
        def _on_example_select(evt: gr.SelectData):
            idx = int(evt.index)
            if 0 <= idx < len(EXAMPLE_SCENES):
                return str(EXAMPLE_SCENES[idx]["recon"]), gr.update(interactive=True)
            return "", gr.update(interactive=False)

        example_gallery.select(
            _on_example_select, None, [artifact_state, session_btn],
        ).then(
            live_session, [artifact_state], [viewer_html, session_status],
            show_progress="hidden",
        )


# ---------------------------------------------------------------------------
# Launch Gradio (which starts uvicorn) and attach the viser reverse-proxy routes
# onto gradio's own FastAPI app. Using demo.launch() (not mount_gradio_app) lets
# us pass theme/css the Gradio-6 way and matches the proven viser_app pattern.
# ---------------------------------------------------------------------------
def _attach_proxy_routes(app):
    """PREPEND the /viser-proxy routes so they win over gradio's SPA catch-all.
    Appending (app.add_route) puts them AFTER gradio's catch-all route, which
    then serves index.html for /viser-proxy/* — the iframe ends up showing the
    gradio app again instead of viser. Inserting at the front fixes that."""
    from starlette.routing import Route, WebSocketRoute
    proxy_http, proxy_ws = _build_proxy_handlers()
    methods = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"]
    routes = [
        Route("/viser-proxy/{token}", proxy_http, methods=methods),
        Route("/viser-proxy/{token}/{path:path}", proxy_http, methods=methods),
        WebSocketRoute("/viser-proxy/{token}", proxy_ws),
        WebSocketRoute("/viser-proxy/{token}/{path:path}", proxy_ws),
    ]
    app.router.routes[0:0] = routes  # prepend → highest match priority
    print("[app] viser-proxy routes prepended; ready", flush=True)


def launch_app():
    """Launch gradio and attach the viser reverse-proxy. Done at MODULE level
    (below) so it runs no matter how HF Spaces starts the app — whether it
    `exec`s `python app.py` or `import`s the module and would otherwise call
    demo.launch() itself. prevent_thread_lock=True lets us attach routes after
    launch; we then block to keep the process alive."""
    # Unlimited Gradio-side concurrency: live_session is a long generator that
    # holds for the whole session, so Gradio's default limit of 1 would make a
    # second window queue behind the first and never show. We let ZeroGPU's own
    # scheduler handle GPU allocation/queueing across concurrent sessions.
    demo.queue(default_concurrency_limit=None)
    demo.launch(
        server_name="0.0.0.0",
        server_port=int(os.environ.get("PORT", "7860")),
        prevent_thread_lock=True,
        theme=_theme,
        css=_custom_css,
        # CRITICAL on HF Spaces: Gradio 6 defaults to SSR, which puts a Node
        # proxy on :7860 in front of the Python app on :7861. That Node proxy
        # serves the gradio SSR page for unknown paths, so /viser-proxy/* never
        # reaches our FastAPI routes (the iframe then shows gradio again).
        # Disabling SSR makes Python/FastAPI serve :7860 directly so the proxy
        # routes work — same topology as the local node run.
        ssr_mode=False,
    )
    _attach_proxy_routes(demo.app)
    threading.Event().wait()  # keep the process alive


# Launch at import/exec time (Spaces-robust). Set APP_NO_LAUNCH=1 to import this
# module without starting the server (e.g. for unit tests).
if os.environ.get("APP_NO_LAUNCH") != "1":
    launch_app()