Umut Kocasari Claude Opus 4.8 commited on
Commit
1f2eb37
·
1 Parent(s): 715dbba

Viewer: preload all frames in a client-side three.js player (smooth playback)

Browse files

Replace gradio Model3D + Timer with a custom three.js point-cloud player
embedded via gr.HTML(head=..., js_on_load=...). All per-frame .glb point
clouds are loaded once into the scene; playback animates by toggling frame
visibility, so the points stay on screen the whole time (no per-frame
network/parse → no white flashes) and the full, un-subsampled cloud is kept.
Play/Pause, Speed (fps) and a scrub slider run entirely client-side; a hidden
file list bridges the served .glb URLs to the viewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +231 -68
app.py CHANGED
@@ -526,29 +526,21 @@ def run(
526
 
527
  _say(1.0, "Done.")
528
 
529
- model3d = view_glbs[0] if view_glbs else None
530
  if not has_canon:
531
  log.append("WARNING: model produced no canonical output — canonical "
532
  "video and point tracks were skipped (check the checkpoint).")
533
 
534
  status = "\n".join(f"• {m}" for m in log)
535
- slider_update = gr.Slider(
536
- minimum=0, maximum=max(1, N - 1), step=1, value=0,
537
- label="Track point-cloud frame",
538
- visible=bool(view_glbs) and N > 1,
539
- interactive=N > 1,
540
- )
541
- play_row_update = gr.update(visible=bool(view_glbs) and N > 1)
542
  return (
543
- model3d,
544
  canonical_vid,
545
  depth_vid,
546
  normals_vid,
547
  tracks2d_vid,
548
  tracks_zip,
549
- view_glbs,
550
- slider_update,
551
- play_row_update,
552
  status,
553
  )
554
  except gr.Error:
@@ -558,15 +550,6 @@ def run(
558
  raise gr.Error(f"Inference failed: {e}\n\n{tb[-1500:]}")
559
 
560
 
561
- def pick_frame(idx, view_glbs):
562
- """Swap the 3D viewer to a different frame's track point cloud."""
563
- if not view_glbs:
564
- return None
565
- idx = int(idx)
566
- idx = max(0, min(idx, len(view_glbs) - 1))
567
- return view_glbs[idx]
568
-
569
-
570
  # --------------------------------------------------------------------------- #
571
  # UI
572
  # --------------------------------------------------------------------------- #
@@ -583,11 +566,220 @@ temporally-consistent **3D point tracks**.
583
  [Code](https://github.com/kocasariumut/FaceAnything)
584
  """
585
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
586
 
587
  def build_demo():
588
  with gr.Blocks(title="Face Anything") as demo:
589
  gr.Markdown(DESCRIPTION)
590
- view_state = gr.State([])
591
 
592
  with gr.Row():
593
  # ---------------- inputs ----------------
@@ -645,24 +837,18 @@ def build_demo():
645
 
646
  # ---------------- outputs ----------------
647
  with gr.Column(scale=1):
648
- model3d = gr.Model3D(
649
- label="3D point cloud with colorful tracks",
650
- clear_color=[1.0, 1.0, 1.0, 1.0],
651
- height=420,
 
 
 
 
652
  )
653
- frame_slider = gr.Slider(
654
- 0, 1, value=0, step=1, visible=False,
655
- label="Track point-cloud frame")
656
- # play / pause / speed — animate the point cloud over time
657
- with gr.Row(visible=False) as play_row:
658
- play_btn = gr.Button("▶ Play", size="sm")
659
- pause_btn = gr.Button("⏸ Pause", size="sm")
660
- play_speed = gr.Slider(
661
- 1, 20, value=8, step=1, label="Speed (fps)",
662
- info="Frames/second. Smoothness depends on point-cloud "
663
- "size (each frame is reloaded); pause to orbit freely.")
664
- play_timer = gr.Timer(1.0 / 8, active=False)
665
- playing = gr.State(False)
666
  tracks_zip = gr.File(
667
  label="Download point clouds (.zip: tracks/ + points/)")
668
  with gr.Tab("Canonical (2D)"):
@@ -683,34 +869,12 @@ def build_demo():
683
  inputs=[files, video, mode, process_res, remove_bg,
684
  conf_percentile, n_tracks, track_k, track_threshold,
685
  fps, max_frames],
686
- outputs=[model3d, canonical_vid, depth_vid, normals_vid, tracks2d_vid,
687
- tracks_zip, view_state, frame_slider, play_row, status],
688
  concurrency_limit=1,
689
  )
690
- # .release (not .change) so the viewer reloads only when you let go of the
691
- # slider smoother than reloading a glb on every drag tick.
692
- frame_slider.release(pick_frame, inputs=[frame_slider, view_state],
693
- outputs=model3d)
694
-
695
- # ---------------- playback (dynamic point cloud over time) ----------------
696
- def _advance(idx, view_glbs):
697
- """Timer tick: hop to the next frame's point cloud, looping at the end."""
698
- if not view_glbs:
699
- return gr.update(), gr.update()
700
- nxt = (int(idx) + 1) % len(view_glbs)
701
- return view_glbs[nxt], nxt
702
-
703
- play_timer.tick(_advance, inputs=[frame_slider, view_state],
704
- outputs=[model3d, frame_slider], show_progress="hidden")
705
- play_btn.click(
706
- lambda f: (gr.Timer(value=1.0 / float(f), active=True), True),
707
- inputs=[play_speed], outputs=[play_timer, playing])
708
- pause_btn.click(
709
- lambda: (gr.Timer(active=False), False),
710
- outputs=[play_timer, playing])
711
- play_speed.change(
712
- lambda f, p: gr.Timer(value=1.0 / float(f), active=bool(p)),
713
- inputs=[play_speed, playing], outputs=[play_timer])
714
 
715
  # ---------------- examples (thumbnail shown, click to load + run) ----------------
716
  ex40 = sorted(glob.glob(os.path.join(APP_DIR, "examples", "seq40", "*.png")))[:MAX_IMAGES]
@@ -718,9 +882,8 @@ def build_demo():
718
  run_inputs = [files, video, mode, process_res, remove_bg,
719
  conf_percentile, n_tracks, track_k, track_threshold,
720
  fps, max_frames]
721
- run_outputs = [model3d, canonical_vid, depth_vid, normals_vid,
722
- tracks2d_vid, tracks_zip, view_state, frame_slider,
723
- play_row, status]
724
 
725
  def _thumb():
726
  return gr.Image(value=ex40[0], height=150, show_label=False,
 
526
 
527
  _say(1.0, "Done.")
528
 
 
529
  if not has_canon:
530
  log.append("WARNING: model produced no canonical output — canonical "
531
  "video and point tracks were skipped (check the checkpoint).")
532
 
533
  status = "\n".join(f"• {m}" for m in log)
534
+ # view_glbs is the per-frame track point cloud (.glb), glTF-aligned and
535
+ # track-colored. They go to the hidden file list, whose URLs the
536
+ # client-side three.js player preloads and animates (see VIEWER_JS).
 
 
 
 
537
  return (
538
+ view_glbs or None,
539
  canonical_vid,
540
  depth_vid,
541
  normals_vid,
542
  tracks2d_vid,
543
  tracks_zip,
 
 
 
544
  status,
545
  )
546
  except gr.Error:
 
550
  raise gr.Error(f"Inference failed: {e}\n\n{tb[-1500:]}")
551
 
552
 
 
 
 
 
 
 
 
 
 
553
  # --------------------------------------------------------------------------- #
554
  # UI
555
  # --------------------------------------------------------------------------- #
 
566
  [Code](https://github.com/kocasariumut/FaceAnything)
567
  """
568
 
569
+ # --------------------------------------------------------------------------- #
570
+ # Custom 3D viewer (client-side three.js).
571
+ #
572
+ # gradio's Model3D re-fetches and re-parses a .glb from the server on every
573
+ # frame, so animating it flashes white (the next cloud isn't on the client yet).
574
+ # Instead we load *every* frame's .glb once into a three.js scene and animate by
575
+ # toggling which frame is visible — no per-frame network/parse, the points stay
576
+ # on screen the whole time, and the full (un-subsampled) cloud is kept.
577
+ # --------------------------------------------------------------------------- #
578
+ THREE_HEAD = """
579
+ <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/build/three.min.js"></script>
580
+ <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/examples/js/loaders/GLTFLoader.js"></script>
581
+ <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/examples/js/controls/OrbitControls.js"></script>
582
+ """
583
+
584
+ VIEWER_MARKUP = """
585
+ <div class="fa-viewer-root" style="width:100%;">
586
+ <div class="fa-canvas-wrap" style="position:relative;width:100%;height:420px;background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
587
+ <div class="fa-overlay" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#6b7280;font-family:sans-serif;font-size:14px;text-align:center;padding:0 16px;">Run a reconstruction to view the 3D point tracks here.</div>
588
+ </div>
589
+ <div class="fa-controls" style="display:none;gap:10px;align-items:center;padding:8px 4px 2px;font-family:sans-serif;font-size:13px;flex-wrap:wrap;">
590
+ <button class="fa-play" type="button" style="cursor:pointer;padding:4px 12px;border:1px solid #d1d5db;border-radius:6px;background:#f9fafb;">&#9654; Play</button>
591
+ <span style="display:inline-flex;align-items:center;gap:6px;">Speed <input class="fa-speed" type="range" min="1" max="30" step="1" value="12" style="width:90px;vertical-align:middle;"><span class="fa-fps">12 fps</span></span>
592
+ <input class="fa-scrub" type="range" min="0" max="0" step="1" value="0" style="flex:1;min-width:120px;vertical-align:middle;">
593
+ <span class="fa-frame" style="min-width:64px;text-align:right;color:#374151;">&ndash; / &ndash;</span>
594
+ </div>
595
+ </div>
596
+ """
597
+
598
+ # Runs once when the HTML component mounts; sets up the three.js scene and
599
+ # exposes window.faViewer.load(urls) for the file bridge below to call.
600
+ VIEWER_JS = """
601
+ (function(){
602
+ if (element.__faInit) return;
603
+ element.__faInit = true;
604
+ var wrap = element.querySelector('.fa-canvas-wrap');
605
+ var overlay = element.querySelector('.fa-overlay');
606
+ var controls = element.querySelector('.fa-controls');
607
+ var playBtn = element.querySelector('.fa-play');
608
+ var speedEl = element.querySelector('.fa-speed');
609
+ var fpsEl = element.querySelector('.fa-fps');
610
+ var scrubEl = element.querySelector('.fa-scrub');
611
+ var frameEl = element.querySelector('.fa-frame');
612
+ if (!wrap) return;
613
+ var renderer, scene, camera, orbit, group;
614
+ var frames = [], cur = 0, playing = false, fps = 12, acc = 0, last = 0, loadToken = 0;
615
+
616
+ function waitThree(cb, tries){
617
+ tries = tries || 0;
618
+ if (window.THREE && THREE.GLTFLoader && THREE.OrbitControls) { cb(); }
619
+ else if (tries > 200) { setOverlay('Could not load the 3D viewer (three.js) \\u2014 check your network / ad-blocker.'); }
620
+ else { setTimeout(function(){ waitThree(cb, tries + 1); }, 60); }
621
+ }
622
+ function setOverlay(msg){
623
+ if (!overlay) return;
624
+ if (msg) { overlay.textContent = msg; overlay.style.display = 'flex'; }
625
+ else { overlay.style.display = 'none'; }
626
+ }
627
+ function resize(){
628
+ if (!renderer) return;
629
+ var w = wrap.clientWidth || 1, h = wrap.clientHeight || 1;
630
+ renderer.setSize(w, h, false);
631
+ camera.aspect = w / h; camera.updateProjectionMatrix();
632
+ }
633
+ function applyStyle(root){
634
+ root.traverse(function(o){
635
+ if (o.isPoints && o.material){
636
+ o.material.size = 2.5;
637
+ o.material.sizeAttenuation = false;
638
+ o.material.vertexColors = true;
639
+ o.material.needsUpdate = true;
640
+ }
641
+ });
642
+ }
643
+ function firstPoints(root){
644
+ var found = null;
645
+ root.traverse(function(o){ if (!found && o.isPoints) found = o; });
646
+ return found;
647
+ }
648
+ function showFrame(i){
649
+ if (!frames.length) return;
650
+ if (i < 0) i = 0;
651
+ if (i > frames.length - 1) i = frames.length - 1;
652
+ for (var k = 0; k < frames.length; k++){ frames[k].visible = (k === i); }
653
+ cur = i;
654
+ if (scrubEl) scrubEl.value = String(i);
655
+ if (frameEl) frameEl.textContent = (i + 1) + ' / ' + frames.length;
656
+ }
657
+ function fitCamera(){
658
+ if (!frames.length) return;
659
+ var box = new THREE.Box3().setFromObject(frames[0]);
660
+ if (box.isEmpty()) return;
661
+ var c = box.getCenter(new THREE.Vector3());
662
+ var s = box.getSize(new THREE.Vector3());
663
+ var r = Math.max(s.x, s.y, s.z) * 0.5 || 0.5;
664
+ var d = (r / Math.tan(camera.fov * Math.PI / 360)) * 1.7;
665
+ orbit.target.copy(c);
666
+ camera.near = Math.max(d / 200, 0.0005);
667
+ camera.far = d * 50 + r * 20;
668
+ camera.position.set(c.x, c.y, c.z + d);
669
+ camera.updateProjectionMatrix();
670
+ orbit.update();
671
+ }
672
+ function clearFrames(){
673
+ for (var k = 0; k < frames.length; k++){
674
+ group.remove(frames[k]);
675
+ if (frames[k].geometry) frames[k].geometry.dispose();
676
+ if (frames[k].material) frames[k].material.dispose();
677
+ }
678
+ frames = []; cur = 0;
679
+ }
680
+ function play(){ if (frames.length < 2) return; playing = true; last = 0; acc = 0; if (playBtn) playBtn.innerHTML = '\\u23F8 Pause'; }
681
+ function pause(){ playing = false; if (playBtn) playBtn.innerHTML = '\\u25B6 Play'; }
682
+ function toggle(){ if (playing) pause(); else play(); }
683
+ function animate(ts){
684
+ requestAnimationFrame(animate);
685
+ if (orbit) orbit.update();
686
+ if (playing && frames.length > 1){
687
+ if (!last) last = ts;
688
+ acc += (ts - last); last = ts;
689
+ var interval = 1000 / Math.max(1, fps);
690
+ if (acc >= interval){
691
+ var steps = Math.floor(acc / interval);
692
+ acc -= steps * interval;
693
+ showFrame((cur + steps) % frames.length);
694
+ }
695
+ } else { last = ts; }
696
+ if (renderer && scene && camera) renderer.render(scene, camera);
697
+ }
698
+ function initThree(){
699
+ if (renderer) return;
700
+ renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
701
+ renderer.setPixelRatio(1);
702
+ renderer.setClearColor(0xffffff, 1);
703
+ if (THREE.sRGBEncoding) renderer.outputEncoding = THREE.sRGBEncoding;
704
+ renderer.domElement.style.display = 'block';
705
+ renderer.domElement.style.width = '100%';
706
+ renderer.domElement.style.height = '100%';
707
+ wrap.appendChild(renderer.domElement);
708
+ scene = new THREE.Scene();
709
+ scene.background = new THREE.Color(0xffffff);
710
+ camera = new THREE.PerspectiveCamera(50, 1, 0.001, 1000);
711
+ camera.position.set(0, 0, 2);
712
+ orbit = new THREE.OrbitControls(camera, renderer.domElement);
713
+ orbit.enableDamping = true; orbit.dampingFactor = 0.1;
714
+ group = new THREE.Group();
715
+ scene.add(group);
716
+ resize();
717
+ if (window.ResizeObserver) { new ResizeObserver(resize).observe(wrap); }
718
+ else { window.addEventListener('resize', resize); }
719
+ requestAnimationFrame(animate);
720
+ }
721
+ function finishLoad(token, loaded){
722
+ if (token !== loadToken) return;
723
+ frames = loaded.filter(function(p){ return p; });
724
+ if (!frames.length){ setOverlay('Failed to load the 3D point clouds.'); return; }
725
+ for (var k = 0; k < frames.length; k++){ frames[k].visible = false; group.add(frames[k]); }
726
+ setOverlay('');
727
+ if (controls) controls.style.display = 'flex';
728
+ if (scrubEl){ scrubEl.min = '0'; scrubEl.max = String(frames.length - 1); scrubEl.value = '0'; }
729
+ fitCamera();
730
+ showFrame(0);
731
+ if (frames.length > 1) play();
732
+ }
733
+ function load(urls){
734
+ waitThree(function(){
735
+ initThree();
736
+ var token = ++loadToken;
737
+ pause();
738
+ clearFrames();
739
+ if (controls) controls.style.display = 'none';
740
+ if (!urls || !urls.length){ setOverlay('No 3D point tracks for this run.'); return; }
741
+ setOverlay('Loading 3D sequence\\u2026 0 / ' + urls.length);
742
+ var loader = new THREE.GLTFLoader();
743
+ var loaded = new Array(urls.length);
744
+ var done = 0;
745
+ function tick(){
746
+ done++;
747
+ setOverlay('Loading 3D sequence\\u2026 ' + done + ' / ' + urls.length);
748
+ if (done === urls.length) finishLoad(token, loaded);
749
+ }
750
+ urls.forEach(function(url, idx){
751
+ loader.load(url, function(gltf){
752
+ if (token !== loadToken) return;
753
+ var pts = firstPoints(gltf.scene);
754
+ if (pts){ applyStyle(pts); loaded[idx] = pts; }
755
+ tick();
756
+ }, undefined, function(){ if (token === loadToken){ loaded[idx] = null; tick(); } });
757
+ });
758
+ });
759
+ }
760
+ if (playBtn) playBtn.addEventListener('click', toggle);
761
+ if (speedEl) speedEl.addEventListener('input', function(){ fps = parseInt(speedEl.value, 10) || 12; if (fpsEl) fpsEl.textContent = fps + ' fps'; });
762
+ if (scrubEl) scrubEl.addEventListener('input', function(){ pause(); showFrame(parseInt(scrubEl.value, 10) || 0); });
763
+ window.faViewer = { load: load, play: play, pause: pause, setFrame: showFrame };
764
+ waitThree(function(){ initThree(); });
765
+ })();
766
+ """
767
+
768
+ # Bridge: when the hidden file list (served .glb URLs) changes, hand the URLs to
769
+ # the three.js viewer. Runs purely client-side (no server round-trip).
770
+ ANIM_BRIDGE_JS = """
771
+ (files) => {
772
+ try {
773
+ var list = (files || []).map(function(f){ return f && (f.url || f.path); }).filter(Boolean);
774
+ if (window.faViewer) { window.faViewer.load(list); }
775
+ } catch (e) { console.error('faViewer load error', e); }
776
+ }
777
+ """
778
+
779
 
780
  def build_demo():
781
  with gr.Blocks(title="Face Anything") as demo:
782
  gr.Markdown(DESCRIPTION)
 
783
 
784
  with gr.Row():
785
  # ---------------- inputs ----------------
 
837
 
838
  # ---------------- outputs ----------------
839
  with gr.Column(scale=1):
840
+ gr.Markdown("**3D point cloud with colorful tracks** · "
841
+ "loads the whole sequence, then plays smoothly")
842
+ # client-side three.js player: all frames preloaded once, then
843
+ # animated by visibility toggle (no per-frame reload → no white
844
+ # flashes; full, un-subsampled cloud). See VIEWER_JS above.
845
+ viewer = gr.HTML(
846
+ value=VIEWER_MARKUP, head=THREE_HEAD, js_on_load=VIEWER_JS,
847
+ show_label=False,
848
  )
849
+ # hidden: run() puts the per-frame .glb files here so gradio
850
+ # serves them; ANIM_BRIDGE_JS hands their URLs to the viewer.
851
+ anim_files = gr.File(file_count="multiple", visible=False)
 
 
 
 
 
 
 
 
 
 
852
  tracks_zip = gr.File(
853
  label="Download point clouds (.zip: tracks/ + points/)")
854
  with gr.Tab("Canonical (2D)"):
 
869
  inputs=[files, video, mode, process_res, remove_bg,
870
  conf_percentile, n_tracks, track_k, track_threshold,
871
  fps, max_frames],
872
+ outputs=[anim_files, canonical_vid, depth_vid, normals_vid,
873
+ tracks2d_vid, tracks_zip, status],
874
  concurrency_limit=1,
875
  )
876
+ # when the served .glb list changes, push the URLs to the three.js player
877
+ anim_files.change(None, inputs=anim_files, outputs=None, js=ANIM_BRIDGE_JS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
878
 
879
  # ---------------- examples (thumbnail shown, click to load + run) ----------------
880
  ex40 = sorted(glob.glob(os.path.join(APP_DIR, "examples", "seq40", "*.png")))[:MAX_IMAGES]
 
882
  run_inputs = [files, video, mode, process_res, remove_bg,
883
  conf_percentile, n_tracks, track_k, track_threshold,
884
  fps, max_frames]
885
+ run_outputs = [anim_files, canonical_vid, depth_vid, normals_vid,
886
+ tracks2d_vid, tracks_zip, status]
 
887
 
888
  def _thumb():
889
  return gr.Image(value=ex40[0], height=150, show_label=False,