File size: 8,478 Bytes
9a25493
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Persistent streaming mesh and skeleton viewer for Gradio 6.28.0."""

from pathlib import Path

import gradio as gr


HERE = Path(__file__).resolve().parent

HTML = """
<div class="motion-viewer">
  <div class="viewer-heading">
    <span class="viewer-action">Motion</span>
    <span class="viewer-status" role="status">Ready</span>
  </div>
  <div class="viewer-stage">
    <div class="viewer-focus-hint">Click the view to use WASD</div>
    <div class="viewer-body-notice"><span class="viewer-body-status" role="status"></span><button class="viewer-body-retry" type="button" hidden>Retry body loading</button></div>
  </div>
  <div class="viewer-controls">
    <div class="viewer-key-legend"><span><kbd data-key="KeyW">W</kbd><kbd data-key="KeyS">S</kbd> Camera forward / back</span><span><kbd data-key="KeyA">A</kbd><kbd data-key="KeyD">D</kbd> Left / right</span><span><kbd data-key="Shift">Shift</kbd> Walk → run</span></div>
    <div class="viewer-slots" aria-label="Prompt selection"><span data-slot="0">1</span><span data-slot="1">2</span><span data-slot="2">3</span><span data-slot="3">4</span><span class="viewer-slot-label">Prompt 1</span></div>
    <span class="viewer-lead">Lead 0.00 s</span>
  </div>
  <div class="viewer-camera-hint">Drag to orbit · Scroll to zoom · Double-click to reset view</div>
  <div class="viewer-path-legend"><span class="path-reached">Reached</span><span class="path-ahead">Ahead</span><span class="path-target">◇ Target</span><span class="path-spring">○ Input</span></div>
  <div class="viewer-stats">
    <div><span>Runtime</span><strong data-stat="runtime">0.0 s</strong></div>
    <div><span>Generation</span><strong data-stat="generation">— fps</strong></div>
    <div><span>Playback</span><strong data-stat="playback">0 fps</strong></div>
    <div><span>Buffer</span><strong data-stat="buffer">0.00 s</strong></div>
  </div>
</div>
"""

CSS = """
.motion-viewer { overflow: hidden; border: 1px solid #dce3e8; border-radius: 16px; background: #f8fafb; color: #233340; font-family: inherit; }
.viewer-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 20px 0; }
.viewer-action { font-size: 16px; font-weight: 600; }
.viewer-status { color: #667782; font-size: 12px; }
.viewer-stage { position: relative; width: 100%; height: clamp(300px, 48vw, 490px); overflow: hidden; }
.viewer-stage canvas { display: block; width: 100%; height: 100%; outline: none; cursor: grab; touch-action: none; }
.viewer-stage canvas:active { cursor: grabbing; }
.viewer-stage:focus-within { box-shadow: inset 0 0 0 2px #397ad5; }
.viewer-focus-hint { position: absolute; bottom: 10px; left: 0; right: 0; z-index: 1; text-align: center; pointer-events: none; font-size: 12px; color: #647582; }
.viewer-stage:focus-within .viewer-focus-hint { opacity: 0; }
.viewer-body-notice { position: absolute; top: 12px; right: 18px; z-index: 1; color: #647582; font-size: 11px; display: flex; align-items: center; gap: 8px; }
.viewer-body-retry { border: 1px solid #ced8df; border-radius: 5px; padding: 4px 7px; background: white; color: #445b6a; cursor: pointer; font: inherit; }
.viewer-body-retry[hidden] { display: none; }
.viewer-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 20px; padding: 12px 18px 5px; font-size: 12px; }
.viewer-key-legend { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; }
.viewer-key-legend > span { white-space: nowrap; }
kbd { display: inline-block; min-width: 22px; margin-right: 3px; padding: 2px 5px; border: 1px solid #ced8df; border-radius: 4px; color: #445b6a; background: white; font: inherit; text-align: center; }
kbd.is-down { color: white; background: #397ad5; border-color: #397ad5; }
kbd[data-key="Shift"].is-down { background: #8b5cdd; border-color: #8b5cdd; }
.viewer-slots { display: flex; align-items: center; gap: 5px; }
.viewer-slots [data-slot] { width: 22px; height: 22px; line-height: 20px; text-align: center; border: 1px solid currentColor; border-radius: 50%; opacity: .6; }
.viewer-slots [data-slot="0"] { color: #397ad5; }
.viewer-slots [data-slot="1"] { color: #8b5cdd; }
.viewer-slots [data-slot="2"] { color: #2d9e72; }
.viewer-slots [data-slot="3"] { color: #d55a9a; }
.viewer-slots [data-slot].is-selected { opacity: 1; border-width: 2px; font-weight: 700; line-height: 18px; }
.viewer-slot-label { margin-left: 4px; }
.viewer-lead { margin-left: auto; color: #667782; font-variant-numeric: tabular-nums; }
.viewer-camera-hint { padding: 4px 18px 8px; color: #667782; font-size: 11px; }
.viewer-path-legend { display: flex; gap: 16px; padding: 4px 18px 12px; color: #667782; font-size: 11px; }
.viewer-path-legend span::before { content: ""; display: inline-block; width: 18px; margin-right: 5px; border-top: 2px solid #397ad5; vertical-align: middle; }
.viewer-path-legend .path-reached::before { opacity: .55; }
.viewer-path-legend .path-ahead::before { border-top-style: dashed; }
.viewer-path-legend .path-target::before, .viewer-path-legend .path-spring::before { display: none; }
.viewer-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid #e4eaee; background: #fff; }
.viewer-stats > div { padding: 14px 16px; min-width: 0; }
.viewer-stats span { display: block; font-size: 11px; color: #6c7b86; margin-bottom: 5px; }
.viewer-stats strong { display: block; font-size: 17px; font-weight: 550; font-variant-numeric: tabular-nums; white-space: nowrap; }
@media (max-width: 500px) { .viewer-heading { padding: 14px 14px 0; } .viewer-stats > div { padding: 12px 8px; } .viewer-stats strong { font-size: 14px; } .viewer-stats span { font-size: 10px; } }
"""


def create_viewer(*, require_body_ready: bool = False) -> gr.HTML:
    """Return one HTML output; yield native dictionaries to update its value.

    Each packet contains session_id, sequence, start_frame, frames [T,22,3],
    fps, generated_frames, elapsed_seconds, inference_fps, action and status.
    Frames use world XYZ, with Y up. A new session_id resets playback.
    mesh_pose contains rotations (base64 float32 LE [T,22,3,3], row-major local
    matrices with the world root first), origins (world pelvis [T,3]), and
    frame_count=T. The fixed neutral body is loaded once and deformed on each
    displayed frame using all skinning weights and the full body pose corrective.
    With require_body_ready=True, bind viewer.body_ready to enable Start only
    after the browser has loaded the body; loading assets consumes no GPU time.
    Optional mesh_topology contains faces (base64 uint16 LE), vertex_count=6890
    and face_count=13776, sent once per session. Optional mesh contains vertices
    (base64 float16 LE, C-order [T,6890,3], relative to each supplied root),
    origins [T,3] in world XYZ, frame_count=T and vertex_count=6890. Mesh, joints
    and frame_meta share start_frame and one playback queue. Canvas fallback
    and packets without mesh continue to display the skeleton.
    Optional control_points and frame_meta retain backend coordinates unchanged.
    With control_points_delta=True, points are merged by frame (up to 2000);
    otherwise control_points is a complete replacement. New sessions reset it.
    Bind ``viewer.control(fn, inputs=[], outputs=[], queue=False,
    trigger_mode="multiple")``; an annotated gr.EventData receives the control
    dictionary through ``evt._data``. This event never writes ``value``.
    """
    vendor = (HERE / "three.min.js").read_text(encoding="utf-8")
    body_script = (HERE / "body.js").read_text(encoding="utf-8")
    script = (HERE / "viewer.js").read_text(encoding="utf-8")
    body_manifest = HERE / "assets" / "body.json"
    body_data = HERE / "assets" / "body.bin"
    gr.set_static_paths(paths=[body_manifest, body_data])
    # The local UMD build is loaded once; no CDN or file-serving setup is needed.
    js = "if (!globalThis.THREE) {\n" + vendor + "\n}\n" + body_script + "\n" + script
    return gr.HTML(
        value={"session_id": "", "sequence": -1, "frames": [], "status": "idle"},
        html_template=HTML,
        css_template=CSS,
        js_on_load=js,
        apply_default_css=False,
        container=False,
        padding=False,
        elem_id="live-motion-viewer",
        body_manifest_url="/gradio_api/file=" + body_manifest.resolve().as_posix(),
        body_data_url="/gradio_api/file=" + body_data.resolve().as_posix(),
        require_body_ready=require_body_ready,
    )