gradio_mmd_viewer 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gradio_mmd_viewer-0.0.1/PKG-INFO +79 -0
- gradio_mmd_viewer-0.0.1/README.md +55 -0
- gradio_mmd_viewer-0.0.1/backend/gradio_mmd_viewer/__init__.py +3 -0
- gradio_mmd_viewer-0.0.1/backend/gradio_mmd_viewer/mmdviewer.py +292 -0
- gradio_mmd_viewer-0.0.1/backend/gradio_mmd_viewer/mmdviewer.pyi +357 -0
- gradio_mmd_viewer-0.0.1/pyproject.toml +55 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gradio_mmd_viewer
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Gradio custom component: 3D MMD model viewer powered by Babylon.js + babylon-mmd
|
|
5
|
+
Keywords: gradio-custom-component,mmd,babylonjs,3d-viewer,miku-miku-dance
|
|
6
|
+
Author: Winterreisender
|
|
7
|
+
Author-email: Winterreisender <winterreisender@foxmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
16
|
+
Requires-Dist: gradio>=6.0,<7.0
|
|
17
|
+
Requires-Dist: uvicorn>=0.51.0
|
|
18
|
+
Requires-Dist: websockets>=16.1
|
|
19
|
+
Requires-Dist: build ; extra == 'dev'
|
|
20
|
+
Requires-Python: >=3.13
|
|
21
|
+
Project-URL: repository, https://github.com/Winterreisender/gradio-mmd-viewer
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# gradio_mmd_widget
|
|
26
|
+
|
|
27
|
+
> [!WARNING] Unewviewed AI code
|
|
28
|
+
> Unewviewed AI code still remains.
|
|
29
|
+
|
|
30
|
+
> A Gradio custom component for rendering MMD motion data in the browser with Babylon.js and babylon-mmd.
|
|
31
|
+
|
|
32
|
+
## Why this component?
|
|
33
|
+
|
|
34
|
+
- Play VMD motion files with a built-in MMD character
|
|
35
|
+
- Use it directly as a Gradio input or output component
|
|
36
|
+
- Great for demos, interactive storytelling, and lightweight 3D previews
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install gradio_mmd_viewer
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import gradio as gr
|
|
48
|
+
from gradio_mmd_viewer import MMDViewer
|
|
49
|
+
|
|
50
|
+
with gr.Blocks() as demo:
|
|
51
|
+
gr.Markdown("## MMD Viewer")
|
|
52
|
+
viewer = MMDViewer(
|
|
53
|
+
value="asset/demo.vmd",
|
|
54
|
+
label="MMD Viewer",
|
|
55
|
+
scale=2,
|
|
56
|
+
min_width=800,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
demo.launch()
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- Built-in model and default motion assets
|
|
66
|
+
- Supports VMD URL input for switching animations
|
|
67
|
+
- Includes a local demo for development and testing
|
|
68
|
+
|
|
69
|
+
## Demo
|
|
70
|
+
|
|
71
|
+
Run the bundled demo with:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python demo/app.py
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Development notes
|
|
78
|
+
|
|
79
|
+
Technical implementation details, architecture notes, build steps, and troubleshooting guidance are maintained in [AGENTS.md](AGENTS.md).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# gradio_mmd_widget
|
|
2
|
+
|
|
3
|
+
> [!WARNING] Unewviewed AI code
|
|
4
|
+
> Unewviewed AI code still remains.
|
|
5
|
+
|
|
6
|
+
> A Gradio custom component for rendering MMD motion data in the browser with Babylon.js and babylon-mmd.
|
|
7
|
+
|
|
8
|
+
## Why this component?
|
|
9
|
+
|
|
10
|
+
- Play VMD motion files with a built-in MMD character
|
|
11
|
+
- Use it directly as a Gradio input or output component
|
|
12
|
+
- Great for demos, interactive storytelling, and lightweight 3D previews
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install gradio_mmd_viewer
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
import gradio as gr
|
|
24
|
+
from gradio_mmd_viewer import MMDViewer
|
|
25
|
+
|
|
26
|
+
with gr.Blocks() as demo:
|
|
27
|
+
gr.Markdown("## MMD Viewer")
|
|
28
|
+
viewer = MMDViewer(
|
|
29
|
+
value="asset/demo.vmd",
|
|
30
|
+
label="MMD Viewer",
|
|
31
|
+
scale=2,
|
|
32
|
+
min_width=800,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
demo.launch()
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- Built-in model and default motion assets
|
|
42
|
+
- Supports VMD URL input for switching animations
|
|
43
|
+
- Includes a local demo for development and testing
|
|
44
|
+
|
|
45
|
+
## Demo
|
|
46
|
+
|
|
47
|
+
Run the bundled demo with:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python demo/app.py
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Development notes
|
|
54
|
+
|
|
55
|
+
Technical implementation details, architecture notes, build steps, and troubleshooting guidance are maintained in [AGENTS.md](AGENTS.md).
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
|
|
12
|
+
from gradio.components.base import Component, FormComponent
|
|
13
|
+
from gradio.events import Events
|
|
14
|
+
from gradio.i18n import I18nData
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gradio.components import Timer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _MMDViewer(FormComponent):
|
|
21
|
+
"""
|
|
22
|
+
Core MMD model viewer component using Babylon.js + babylon-mmd.
|
|
23
|
+
Handles only the Gradio component lifecycle — no path resolution or ZIP extraction.
|
|
24
|
+
Use `MMDViewer` (the shell) for end-user convenience features like PMX ZIP extraction.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
EVENTS = [
|
|
28
|
+
Events.change,
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
value: str | Callable | None = None,
|
|
34
|
+
*,
|
|
35
|
+
model_url: str | None = None,
|
|
36
|
+
physics_enabled: bool = False,
|
|
37
|
+
grey_mode: bool = False,
|
|
38
|
+
camera_position: tuple[float, float, float] = (0.0, 22.0, -25.0),
|
|
39
|
+
camera_rotation: tuple[float, float] = (0.0, 0.0),
|
|
40
|
+
audio_url: str | None = None,
|
|
41
|
+
label: str | I18nData | None = None,
|
|
42
|
+
every: Timer | float | None = None,
|
|
43
|
+
inputs: Component | Sequence[Component] | set[Component] | None = None,
|
|
44
|
+
show_label: bool | None = None,
|
|
45
|
+
scale: int | None = None,
|
|
46
|
+
min_width: int = 160,
|
|
47
|
+
interactive: bool | None = None,
|
|
48
|
+
visible: bool | Literal["hidden"] = True,
|
|
49
|
+
elem_id: str | None = None,
|
|
50
|
+
elem_classes: list[str] | str | None = None,
|
|
51
|
+
render: bool = True,
|
|
52
|
+
key: int | str | tuple[int | str, ...] | None = None,
|
|
53
|
+
preserved_by_key: list[str] | str | None = "value",
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Parameters:
|
|
57
|
+
value: URL to a VMD motion file. The 3D viewer loads the built-in model
|
|
58
|
+
and plays this motion. If a callable is provided, it's called on app load.
|
|
59
|
+
model_url: URL to a PMX/BPMX model file. If supplied, the viewer loads this
|
|
60
|
+
model instead of the built-in one.
|
|
61
|
+
physics_enabled: if True, enables physics simulation for the model.
|
|
62
|
+
grey_mode: if True, renders the model in flat grey (no textures).
|
|
63
|
+
camera_position: initial camera position in the 3D scene.
|
|
64
|
+
camera_rotation: initial camera rotation as (alpha, beta) in radians.
|
|
65
|
+
audio_url: URL to an audio file for animation playback synchronization.
|
|
66
|
+
label: the label for this component.
|
|
67
|
+
every: Continuously calls `value` to recalculate it if `value` is a function.
|
|
68
|
+
inputs: Components used as inputs to calculate `value` if it's a function.
|
|
69
|
+
show_label: if True, will display label.
|
|
70
|
+
scale: relative size compared to adjacent Components.
|
|
71
|
+
min_width: minimum pixel width.
|
|
72
|
+
interactive: if True, component accepts user input; if False, display-only.
|
|
73
|
+
visible: If False, component will be hidden.
|
|
74
|
+
elem_id: HTML DOM id for this component.
|
|
75
|
+
elem_classes: HTML DOM classes for this component.
|
|
76
|
+
render: If False, component won't render in the Blocks context.
|
|
77
|
+
key: Components with the same key across re-renders are treated as same component.
|
|
78
|
+
preserved_by_key: Params preserved across re-renders when key matches.
|
|
79
|
+
"""
|
|
80
|
+
self.model_url = model_url
|
|
81
|
+
self.physics_enabled = physics_enabled
|
|
82
|
+
self.grey_mode = grey_mode
|
|
83
|
+
self.camera_position = camera_position
|
|
84
|
+
self.camera_rotation = camera_rotation
|
|
85
|
+
self.audio_url = audio_url
|
|
86
|
+
super().__init__(
|
|
87
|
+
label=label,
|
|
88
|
+
every=every,
|
|
89
|
+
inputs=inputs,
|
|
90
|
+
show_label=show_label,
|
|
91
|
+
scale=scale,
|
|
92
|
+
min_width=min_width,
|
|
93
|
+
interactive=interactive,
|
|
94
|
+
visible=visible,
|
|
95
|
+
elem_id=elem_id,
|
|
96
|
+
elem_classes=elem_classes,
|
|
97
|
+
value=value,
|
|
98
|
+
render=render,
|
|
99
|
+
key=key,
|
|
100
|
+
preserved_by_key=preserved_by_key,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def preprocess(self, payload: str | None) -> str | None:
|
|
104
|
+
"""
|
|
105
|
+
Parameters:
|
|
106
|
+
payload: the VMD URL from the frontend.
|
|
107
|
+
Returns:
|
|
108
|
+
The VMD URL as a string.
|
|
109
|
+
"""
|
|
110
|
+
return None if payload is None else str(payload)
|
|
111
|
+
|
|
112
|
+
def postprocess(self, value: str | None) -> str | None:
|
|
113
|
+
"""
|
|
114
|
+
Parameters:
|
|
115
|
+
value: A VMD URL string to send to the frontend.
|
|
116
|
+
Returns:
|
|
117
|
+
The VMD URL as-is.
|
|
118
|
+
"""
|
|
119
|
+
return None if value is None else str(value)
|
|
120
|
+
|
|
121
|
+
def api_info(self) -> dict[str, Any]:
|
|
122
|
+
return {"type": "string"}
|
|
123
|
+
|
|
124
|
+
def example_payload(self) -> Any:
|
|
125
|
+
return "Hello!!"
|
|
126
|
+
|
|
127
|
+
def example_value(self) -> Any:
|
|
128
|
+
return "Hello!!"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_absolute_file_path(path: str) -> bool:
|
|
132
|
+
"""Check if `path` is an absolute filesystem path that needs URL conversion."""
|
|
133
|
+
if len(path) >= 2 and path[1] == ":" and path[0].isalpha():
|
|
134
|
+
return True # Windows drive letter: C:\...
|
|
135
|
+
if path.startswith("/") and not path.startswith("/gradio_api/"):
|
|
136
|
+
return True # Unix absolute, but not already a Gradio URL
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _to_gradio_file_url(file_path: str) -> str:
|
|
141
|
+
"""Convert an absolute filesystem path to /gradio_api/file=... URL."""
|
|
142
|
+
return f"/gradio_api/file={file_path.replace(chr(92), '/')}"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _get_mmd_cache_dir() -> str:
|
|
146
|
+
"""Get or create the mmd_cache directory under Gradio's upload folder (always in allowed_paths)."""
|
|
147
|
+
cache_dir = os.path.join(tempfile.gettempdir(), "gradio", "mmd_cache")
|
|
148
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
149
|
+
return cache_dir
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _ensure_servable(file_path: str) -> str:
|
|
153
|
+
"""Copy a file to Gradio's upload cache and return its /gradio_api/file=... URL.
|
|
154
|
+
|
|
155
|
+
Idempotent — if the file has already been cached, reuses it.
|
|
156
|
+
Relative paths and existing Gradio URLs pass through unchanged.
|
|
157
|
+
"""
|
|
158
|
+
if not _is_absolute_file_path(file_path):
|
|
159
|
+
return file_path
|
|
160
|
+
|
|
161
|
+
cache_dir = _get_mmd_cache_dir()
|
|
162
|
+
basename = os.path.basename(file_path)
|
|
163
|
+
name, ext = os.path.splitext(basename)
|
|
164
|
+
hash_suffix = hashlib.md5(file_path.encode()).hexdigest()[:8]
|
|
165
|
+
dest = os.path.join(cache_dir, f"{name}_{hash_suffix}{ext}")
|
|
166
|
+
|
|
167
|
+
if not os.path.exists(dest):
|
|
168
|
+
shutil.copy2(file_path, dest)
|
|
169
|
+
|
|
170
|
+
return _to_gradio_file_url(dest)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def extract_pmx_zip(zip_path: str) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Extract a PMX ZIP file to .mmd_cache/, find the .pmx file,
|
|
176
|
+
and return the /gradio_api/file=... URL.
|
|
177
|
+
|
|
178
|
+
Use this both in __init__ and in callbacks that produce gr.update():
|
|
179
|
+
|
|
180
|
+
model_url = extract_pmx_zip(zip_path)
|
|
181
|
+
return gr.update(model_url=model_url, ...)
|
|
182
|
+
|
|
183
|
+
Raises FileNotFoundError if no .pmx file is found in the archive.
|
|
184
|
+
"""
|
|
185
|
+
cache_dir = _get_mmd_cache_dir()
|
|
186
|
+
zip_hash = hashlib.md5(zip_path.encode()).hexdigest()[:8]
|
|
187
|
+
extract_dir = os.path.join(cache_dir, f"pmx_{zip_hash}")
|
|
188
|
+
os.makedirs(extract_dir, exist_ok=True)
|
|
189
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
190
|
+
zf.extractall(extract_dir)
|
|
191
|
+
|
|
192
|
+
candidates = glob.glob(
|
|
193
|
+
os.path.join(glob.escape(extract_dir), "**", "*.[pP][mM][xX]"),
|
|
194
|
+
recursive=True,
|
|
195
|
+
)
|
|
196
|
+
if not candidates:
|
|
197
|
+
raise FileNotFoundError(f"No .pmx file found in archive: {zip_path}")
|
|
198
|
+
|
|
199
|
+
pmx_file = candidates[0]
|
|
200
|
+
|
|
201
|
+
return _to_gradio_file_url(pmx_file)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class MMDViewer(_MMDViewer):
|
|
205
|
+
"""
|
|
206
|
+
Thin shell around _MMDViewer — accepts filesystem paths only, never URLs.
|
|
207
|
+
|
|
208
|
+
- `value`: VMD motion file path. Absolute paths auto-converted to /gradio_api/file=...
|
|
209
|
+
via postprocess(). Relative paths pass through as-is (Gradio serves them as assets).
|
|
210
|
+
- `pmx_zip`: path to a .zip containing a PMX model → extracted → model_url.
|
|
211
|
+
|
|
212
|
+
For pmx_zip in gr.update(), compose explicitly:
|
|
213
|
+
|
|
214
|
+
from gradio_mmd_viewer import extract_pmx_zip
|
|
215
|
+
model_url = extract_pmx_zip(zip_path)
|
|
216
|
+
return gr.update(model_url=model_url, ...)
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
def __init__(
|
|
220
|
+
self,
|
|
221
|
+
value: str | Callable | None = None,
|
|
222
|
+
*,
|
|
223
|
+
pmx_zip: str | None = None,
|
|
224
|
+
model_url: str | None = None,
|
|
225
|
+
physics_enabled: bool = False,
|
|
226
|
+
grey_mode: bool = False,
|
|
227
|
+
camera_position: tuple[float, float, float] = (0.0, 38.0, -25.0),
|
|
228
|
+
camera_rotation: tuple[float, float] = (0.0, 0.0),
|
|
229
|
+
audio_url: str | None = None,
|
|
230
|
+
label: str | I18nData | None = None,
|
|
231
|
+
every: Timer | float | None = None,
|
|
232
|
+
inputs: Component | Sequence[Component] | set[Component] | None = None,
|
|
233
|
+
show_label: bool | None = None,
|
|
234
|
+
scale: int | None = None,
|
|
235
|
+
min_width: int = 160,
|
|
236
|
+
interactive: bool | None = None,
|
|
237
|
+
visible: bool | Literal["hidden"] = True,
|
|
238
|
+
elem_id: str | None = None,
|
|
239
|
+
elem_classes: list[str] | str | None = None,
|
|
240
|
+
render: bool = True,
|
|
241
|
+
key: int | str | tuple[int | str, ...] | None = None,
|
|
242
|
+
preserved_by_key: list[str] | str | None = "value",
|
|
243
|
+
):
|
|
244
|
+
"""
|
|
245
|
+
Parameters:
|
|
246
|
+
value: VMD motion file path — absolute (auto-converted to /gradio_api/file=...)
|
|
247
|
+
or relative (passed through, served as Gradio asset).
|
|
248
|
+
pmx_zip: Path to a .zip containing a PMX model. Extracted → model_url.
|
|
249
|
+
model_url: Direct path/URL to a PMX/BPMX model file. Used as-is.
|
|
250
|
+
physics_enabled: if True, enables physics simulation.
|
|
251
|
+
grey_mode: if True, renders the model in flat grey (no textures).
|
|
252
|
+
camera_position: initial camera position as (x, y, z).
|
|
253
|
+
camera_rotation: initial camera rotation as (alpha, beta) in radians.
|
|
254
|
+
audio_url: URL/path to audio file for playback synchronization.
|
|
255
|
+
"""
|
|
256
|
+
if pmx_zip:
|
|
257
|
+
model_url = extract_pmx_zip(pmx_zip)
|
|
258
|
+
|
|
259
|
+
if audio_url and _is_absolute_file_path(audio_url):
|
|
260
|
+
audio_url = _ensure_servable(audio_url)
|
|
261
|
+
|
|
262
|
+
super().__init__(
|
|
263
|
+
value=value,
|
|
264
|
+
model_url=model_url,
|
|
265
|
+
physics_enabled=physics_enabled,
|
|
266
|
+
grey_mode=grey_mode,
|
|
267
|
+
camera_position=camera_position,
|
|
268
|
+
camera_rotation=camera_rotation,
|
|
269
|
+
audio_url=audio_url,
|
|
270
|
+
label=label,
|
|
271
|
+
every=every,
|
|
272
|
+
inputs=inputs,
|
|
273
|
+
show_label=show_label,
|
|
274
|
+
scale=scale,
|
|
275
|
+
min_width=min_width,
|
|
276
|
+
interactive=interactive,
|
|
277
|
+
visible=visible,
|
|
278
|
+
elem_id=elem_id,
|
|
279
|
+
elem_classes=elem_classes,
|
|
280
|
+
render=render,
|
|
281
|
+
key=key,
|
|
282
|
+
preserved_by_key=preserved_by_key,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# ── postprocess: Gradio's standard hook for path → URL ─────────
|
|
286
|
+
# Called on every value flowing to the frontend, including from gr.update().
|
|
287
|
+
# Copies absolute filesystem paths to .mmd_cache/ and returns /gradio_api/file=...
|
|
288
|
+
def postprocess(self, value: str | None) -> str | None:
|
|
289
|
+
value = super().postprocess(value)
|
|
290
|
+
if value and _is_absolute_file_path(value):
|
|
291
|
+
value = _ensure_servable(value)
|
|
292
|
+
return value
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import zipfile
|
|
9
|
+
from collections.abc import Callable, Sequence
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
11
|
+
|
|
12
|
+
from gradio.components.base import Component, FormComponent
|
|
13
|
+
from gradio.events import Events
|
|
14
|
+
from gradio.i18n import I18nData
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from gradio.components import Timer
|
|
18
|
+
|
|
19
|
+
from gradio.events import Dependency
|
|
20
|
+
|
|
21
|
+
class _MMDViewer(FormComponent):
|
|
22
|
+
"""
|
|
23
|
+
Core MMD model viewer component using Babylon.js + babylon-mmd.
|
|
24
|
+
Handles only the Gradio component lifecycle — no path resolution or ZIP extraction.
|
|
25
|
+
Use `MMDViewer` (the shell) for end-user convenience features like PMX ZIP extraction.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
EVENTS = [
|
|
29
|
+
Events.change,
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
value: str | Callable | None = None,
|
|
35
|
+
*,
|
|
36
|
+
model_url: str | None = None,
|
|
37
|
+
physics_enabled: bool = False,
|
|
38
|
+
grey_mode: bool = False,
|
|
39
|
+
camera_position: tuple[float, float, float] = (0.0, 22.0, -25.0),
|
|
40
|
+
camera_rotation: tuple[float, float] = (0.0, 0.0),
|
|
41
|
+
audio_url: str | None = None,
|
|
42
|
+
label: str | I18nData | None = None,
|
|
43
|
+
every: Timer | float | None = None,
|
|
44
|
+
inputs: Component | Sequence[Component] | set[Component] | None = None,
|
|
45
|
+
show_label: bool | None = None,
|
|
46
|
+
scale: int | None = None,
|
|
47
|
+
min_width: int = 160,
|
|
48
|
+
interactive: bool | None = None,
|
|
49
|
+
visible: bool | Literal["hidden"] = True,
|
|
50
|
+
elem_id: str | None = None,
|
|
51
|
+
elem_classes: list[str] | str | None = None,
|
|
52
|
+
render: bool = True,
|
|
53
|
+
key: int | str | tuple[int | str, ...] | None = None,
|
|
54
|
+
preserved_by_key: list[str] | str | None = "value",
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Parameters:
|
|
58
|
+
value: URL to a VMD motion file. The 3D viewer loads the built-in model
|
|
59
|
+
and plays this motion. If a callable is provided, it's called on app load.
|
|
60
|
+
model_url: URL to a PMX/BPMX model file. If supplied, the viewer loads this
|
|
61
|
+
model instead of the built-in one.
|
|
62
|
+
physics_enabled: if True, enables physics simulation for the model.
|
|
63
|
+
grey_mode: if True, renders the model in flat grey (no textures).
|
|
64
|
+
camera_position: initial camera position in the 3D scene.
|
|
65
|
+
camera_rotation: initial camera rotation as (alpha, beta) in radians.
|
|
66
|
+
audio_url: URL to an audio file for animation playback synchronization.
|
|
67
|
+
label: the label for this component.
|
|
68
|
+
every: Continuously calls `value` to recalculate it if `value` is a function.
|
|
69
|
+
inputs: Components used as inputs to calculate `value` if it's a function.
|
|
70
|
+
show_label: if True, will display label.
|
|
71
|
+
scale: relative size compared to adjacent Components.
|
|
72
|
+
min_width: minimum pixel width.
|
|
73
|
+
interactive: if True, component accepts user input; if False, display-only.
|
|
74
|
+
visible: If False, component will be hidden.
|
|
75
|
+
elem_id: HTML DOM id for this component.
|
|
76
|
+
elem_classes: HTML DOM classes for this component.
|
|
77
|
+
render: If False, component won't render in the Blocks context.
|
|
78
|
+
key: Components with the same key across re-renders are treated as same component.
|
|
79
|
+
preserved_by_key: Params preserved across re-renders when key matches.
|
|
80
|
+
"""
|
|
81
|
+
self.model_url = model_url
|
|
82
|
+
self.physics_enabled = physics_enabled
|
|
83
|
+
self.grey_mode = grey_mode
|
|
84
|
+
self.camera_position = camera_position
|
|
85
|
+
self.camera_rotation = camera_rotation
|
|
86
|
+
self.audio_url = audio_url
|
|
87
|
+
super().__init__(
|
|
88
|
+
label=label,
|
|
89
|
+
every=every,
|
|
90
|
+
inputs=inputs,
|
|
91
|
+
show_label=show_label,
|
|
92
|
+
scale=scale,
|
|
93
|
+
min_width=min_width,
|
|
94
|
+
interactive=interactive,
|
|
95
|
+
visible=visible,
|
|
96
|
+
elem_id=elem_id,
|
|
97
|
+
elem_classes=elem_classes,
|
|
98
|
+
value=value,
|
|
99
|
+
render=render,
|
|
100
|
+
key=key,
|
|
101
|
+
preserved_by_key=preserved_by_key,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def preprocess(self, payload: str | None) -> str | None:
|
|
105
|
+
"""
|
|
106
|
+
Parameters:
|
|
107
|
+
payload: the VMD URL from the frontend.
|
|
108
|
+
Returns:
|
|
109
|
+
The VMD URL as a string.
|
|
110
|
+
"""
|
|
111
|
+
return None if payload is None else str(payload)
|
|
112
|
+
|
|
113
|
+
def postprocess(self, value: str | None) -> str | None:
|
|
114
|
+
"""
|
|
115
|
+
Parameters:
|
|
116
|
+
value: A VMD URL string to send to the frontend.
|
|
117
|
+
Returns:
|
|
118
|
+
The VMD URL as-is.
|
|
119
|
+
"""
|
|
120
|
+
return None if value is None else str(value)
|
|
121
|
+
|
|
122
|
+
def api_info(self) -> dict[str, Any]:
|
|
123
|
+
return {"type": "string"}
|
|
124
|
+
|
|
125
|
+
def example_payload(self) -> Any:
|
|
126
|
+
return "Hello!!"
|
|
127
|
+
|
|
128
|
+
def example_value(self) -> Any:
|
|
129
|
+
return "Hello!!"
|
|
130
|
+
from typing import Callable, Literal, Sequence, Any, TYPE_CHECKING
|
|
131
|
+
from gradio.blocks import Block
|
|
132
|
+
if TYPE_CHECKING:
|
|
133
|
+
from gradio.components import Timer
|
|
134
|
+
from gradio.components.base import Component
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def change(self,
|
|
138
|
+
fn: Callable[..., Any] | None = None,
|
|
139
|
+
inputs: Block | Sequence[Block] | set[Block] | None = None,
|
|
140
|
+
outputs: Block | Sequence[Block] | None = None,
|
|
141
|
+
api_name: str | None = None,
|
|
142
|
+
scroll_to_output: bool = False,
|
|
143
|
+
show_progress: Literal["full", "minimal", "hidden"] = "full",
|
|
144
|
+
show_progress_on: Component | Sequence[Component] | None = None,
|
|
145
|
+
queue: bool | None = None,
|
|
146
|
+
batch: bool = False,
|
|
147
|
+
max_batch_size: int = 4,
|
|
148
|
+
preprocess: bool = True,
|
|
149
|
+
postprocess: bool = True,
|
|
150
|
+
cancels: dict[str, Any] | list[dict[str, Any]] | None = None,
|
|
151
|
+
every: Timer | float | None = None,
|
|
152
|
+
trigger_mode: Literal["once", "multiple", "always_last"] | None = None,
|
|
153
|
+
js: str | Literal[True] | None = None,
|
|
154
|
+
concurrency_limit: int | None | Literal["default"] = "default",
|
|
155
|
+
concurrency_id: str | None = None,
|
|
156
|
+
api_visibility: Literal["public", "private", "undocumented"] = "public",
|
|
157
|
+
key: int | str | tuple[int | str, ...] | None = None,
|
|
158
|
+
api_description: str | None | Literal[False] = None,
|
|
159
|
+
validator: Callable[..., Any] | None = None,
|
|
160
|
+
|
|
161
|
+
) -> Dependency:
|
|
162
|
+
"""
|
|
163
|
+
Parameters:
|
|
164
|
+
fn: the function to call when this event is triggered. Often a machine learning model's prediction function. Each parameter of the function corresponds to one input component, and the function should return a single value or a tuple of values, with each element in the tuple corresponding to one output component.
|
|
165
|
+
inputs: list of gradio.components to use as inputs. If the function takes no inputs, this should be an empty list.
|
|
166
|
+
outputs: list of gradio.components to use as outputs. If the function returns no outputs, this should be an empty list.
|
|
167
|
+
api_name: defines how the endpoint appears in the API docs. Can be a string or None. If set to a string, the endpoint will be exposed in the API docs with the given name. If None (default), the name of the function will be used as the API endpoint.
|
|
168
|
+
scroll_to_output: if True, will scroll to output component on completion
|
|
169
|
+
show_progress: how to show the progress animation while event is running: "full" shows a spinner which covers the output component area as well as a runtime display in the upper right corner, "minimal" only shows the runtime display, "hidden" shows no progress animation at all
|
|
170
|
+
show_progress_on: Component or list of components to show the progress animation on. If None, will show the progress animation on all of the output components.
|
|
171
|
+
queue: if True, will place the request on the queue, if the queue has been enabled. If False, will not put this event on the queue, even if the queue has been enabled. If None, will use the queue setting of the gradio app.
|
|
172
|
+
batch: if True, then the function should process a batch of inputs, meaning that it should accept a list of input values for each parameter. The lists should be of equal length (and be up to length `max_batch_size`). The function is then *required* to return a tuple of lists (even if there is only 1 output component), with each list in the tuple corresponding to one output component.
|
|
173
|
+
max_batch_size: maximum number of inputs to batch together if this is called from the queue (only relevant if batch=True)
|
|
174
|
+
preprocess: if False, will not run preprocessing of component data before running 'fn' (e.g. leaving it as a base64 string if this method is called with the `Image` component).
|
|
175
|
+
postprocess: if False, will not run postprocessing of component data before returning 'fn' output to the browser.
|
|
176
|
+
cancels: a list of other events to cancel when this listener is triggered. For example, setting cancels=[click_event] will cancel the click_event, where click_event is the return value of another components .click method. Functions that have not yet run (or generators that are iterating) will be cancelled, but functions that are currently running will be allowed to finish.
|
|
177
|
+
every: continuously calls `value` to recalculate it if `value` is a function (has no effect otherwise). Can provide a Timer whose tick resets `value`, or a float that provides the regular interval for the reset Timer.
|
|
178
|
+
trigger_mode: if "once" (default for all events except `.change()`) would not allow any submissions while an event is pending. If set to "multiple", unlimited submissions are allowed while pending, and "always_last" (default for `.change()` and `.key_up()` events) would allow a second submission after the pending event is complete.
|
|
179
|
+
js: optional frontend js method to run before running 'fn'. Input arguments for js method are values of 'inputs' and 'outputs', return should be a list of values for output components.
|
|
180
|
+
concurrency_limit: if set, this is the maximum number of this event that can be running simultaneously. Can be set to None to mean no concurrency_limit (any number of this event can be running simultaneously). Set to "default" to use the default concurrency limit (defined by the `default_concurrency_limit` parameter in `Blocks.queue()`, which itself is 1 by default).
|
|
181
|
+
concurrency_id: if set, this is the id of the concurrency group. Events with the same concurrency_id will be limited by the lowest set concurrency_limit.
|
|
182
|
+
api_visibility: controls the visibility and accessibility of this endpoint. Can be "public" (shown in API docs and callable by clients), "private" (hidden from API docs and not callable by the Gradio client libraries), or "undocumented" (hidden from API docs but callable by clients and via gr.load). If fn is None, api_visibility will automatically be set to "private".
|
|
183
|
+
key: A unique key for this event listener to be used in @gr.render(). If set, this value identifies an event as identical across re-renders when the key is identical.
|
|
184
|
+
api_description: Description of the API endpoint. Can be a string, None, or False. If set to a string, the endpoint will be exposed in the API docs with the given description. If None, the function's docstring will be used as the API endpoint description. If False, then no description will be displayed in the API docs.
|
|
185
|
+
validator: Optional validation function to run before the main function. If provided, this function will be executed first with queue=False, and only if it completes successfully will the main function be called. The validator receives the same inputs as the main function.
|
|
186
|
+
|
|
187
|
+
"""
|
|
188
|
+
...
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_absolute_file_path(path: str) -> bool:
|
|
192
|
+
"""Check if `path` is an absolute filesystem path that needs URL conversion."""
|
|
193
|
+
if len(path) >= 2 and path[1] == ":" and path[0].isalpha():
|
|
194
|
+
return True # Windows drive letter: C:\...
|
|
195
|
+
if path.startswith("/") and not path.startswith("/gradio_api/"):
|
|
196
|
+
return True # Unix absolute, but not already a Gradio URL
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _to_gradio_file_url(file_path: str) -> str:
|
|
201
|
+
"""Convert an absolute filesystem path to /gradio_api/file=... URL."""
|
|
202
|
+
return f"/gradio_api/file={file_path.replace(chr(92), '/')}"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _get_mmd_cache_dir() -> str:
|
|
206
|
+
"""Get or create the mmd_cache directory under Gradio's upload folder (always in allowed_paths)."""
|
|
207
|
+
cache_dir = os.path.join(tempfile.gettempdir(), "gradio", "mmd_cache")
|
|
208
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
209
|
+
return cache_dir
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _ensure_servable(file_path: str) -> str:
|
|
213
|
+
"""Copy a file to Gradio's upload cache and return its /gradio_api/file=... URL.
|
|
214
|
+
|
|
215
|
+
Idempotent — if the file has already been cached, reuses it.
|
|
216
|
+
Relative paths and existing Gradio URLs pass through unchanged.
|
|
217
|
+
"""
|
|
218
|
+
if not _is_absolute_file_path(file_path):
|
|
219
|
+
return file_path
|
|
220
|
+
|
|
221
|
+
cache_dir = _get_mmd_cache_dir()
|
|
222
|
+
basename = os.path.basename(file_path)
|
|
223
|
+
name, ext = os.path.splitext(basename)
|
|
224
|
+
hash_suffix = hashlib.md5(file_path.encode()).hexdigest()[:8]
|
|
225
|
+
dest = os.path.join(cache_dir, f"{name}_{hash_suffix}{ext}")
|
|
226
|
+
|
|
227
|
+
if not os.path.exists(dest):
|
|
228
|
+
shutil.copy2(file_path, dest)
|
|
229
|
+
|
|
230
|
+
return _to_gradio_file_url(dest)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def extract_pmx_zip(zip_path: str) -> str:
|
|
234
|
+
"""
|
|
235
|
+
Extract a PMX ZIP file to .mmd_cache/, find the .pmx file,
|
|
236
|
+
and return the /gradio_api/file=... URL.
|
|
237
|
+
|
|
238
|
+
Use this both in __init__ and in callbacks that produce gr.update():
|
|
239
|
+
|
|
240
|
+
model_url = extract_pmx_zip(zip_path)
|
|
241
|
+
return gr.update(model_url=model_url, ...)
|
|
242
|
+
|
|
243
|
+
Raises FileNotFoundError if no .pmx file is found in the archive.
|
|
244
|
+
"""
|
|
245
|
+
cache_dir = _get_mmd_cache_dir()
|
|
246
|
+
zip_hash = hashlib.md5(zip_path.encode()).hexdigest()[:8]
|
|
247
|
+
extract_dir = os.path.join(cache_dir, f"pmx_{zip_hash}")
|
|
248
|
+
os.makedirs(extract_dir, exist_ok=True)
|
|
249
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
250
|
+
zf.extractall(extract_dir)
|
|
251
|
+
|
|
252
|
+
candidates = glob.glob(
|
|
253
|
+
os.path.join(glob.escape(extract_dir), "**", "*.[pP][mM][xX]"),
|
|
254
|
+
recursive=True,
|
|
255
|
+
)
|
|
256
|
+
if not candidates:
|
|
257
|
+
raise FileNotFoundError(f"No .pmx file found in archive: {zip_path}")
|
|
258
|
+
|
|
259
|
+
pmx_file = candidates[0]
|
|
260
|
+
|
|
261
|
+
return _to_gradio_file_url(pmx_file)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class MMDViewer(_MMDViewer):
|
|
265
|
+
"""
|
|
266
|
+
Thin shell around _MMDViewer — accepts filesystem paths only, never URLs.
|
|
267
|
+
|
|
268
|
+
- `value`: VMD motion file path. Absolute paths auto-converted to /gradio_api/file=...
|
|
269
|
+
via postprocess(). Relative paths pass through as-is (Gradio serves them as assets).
|
|
270
|
+
- `pmx_zip`: path to a .zip containing a PMX model → extracted → model_url.
|
|
271
|
+
|
|
272
|
+
For pmx_zip in gr.update(), compose explicitly:
|
|
273
|
+
|
|
274
|
+
from gradio_mmd_viewer import extract_pmx_zip
|
|
275
|
+
model_url = extract_pmx_zip(zip_path)
|
|
276
|
+
return gr.update(model_url=model_url, ...)
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
def __init__(
|
|
280
|
+
self,
|
|
281
|
+
value: str | Callable | None = None,
|
|
282
|
+
*,
|
|
283
|
+
pmx_zip: str | None = None,
|
|
284
|
+
model_url: str | None = None,
|
|
285
|
+
physics_enabled: bool = False,
|
|
286
|
+
grey_mode: bool = False,
|
|
287
|
+
camera_position: tuple[float, float, float] = (0.0, 38.0, -25.0),
|
|
288
|
+
camera_rotation: tuple[float, float] = (0.0, 0.0),
|
|
289
|
+
audio_url: str | None = None,
|
|
290
|
+
label: str | I18nData | None = None,
|
|
291
|
+
every: Timer | float | None = None,
|
|
292
|
+
inputs: Component | Sequence[Component] | set[Component] | None = None,
|
|
293
|
+
show_label: bool | None = None,
|
|
294
|
+
scale: int | None = None,
|
|
295
|
+
min_width: int = 160,
|
|
296
|
+
interactive: bool | None = None,
|
|
297
|
+
visible: bool | Literal["hidden"] = True,
|
|
298
|
+
elem_id: str | None = None,
|
|
299
|
+
elem_classes: list[str] | str | None = None,
|
|
300
|
+
render: bool = True,
|
|
301
|
+
key: int | str | tuple[int | str, ...] | None = None,
|
|
302
|
+
preserved_by_key: list[str] | str | None = "value",
|
|
303
|
+
):
|
|
304
|
+
"""
|
|
305
|
+
Parameters:
|
|
306
|
+
value: VMD motion file path — absolute (auto-converted to /gradio_api/file=...)
|
|
307
|
+
or relative (passed through, served as Gradio asset).
|
|
308
|
+
pmx_zip: Path to a .zip containing a PMX model. Extracted → model_url.
|
|
309
|
+
model_url: Direct path/URL to a PMX/BPMX model file. Used as-is.
|
|
310
|
+
physics_enabled: if True, enables physics simulation.
|
|
311
|
+
grey_mode: if True, renders the model in flat grey (no textures).
|
|
312
|
+
camera_position: initial camera position as (x, y, z).
|
|
313
|
+
camera_rotation: initial camera rotation as (alpha, beta) in radians.
|
|
314
|
+
audio_url: URL/path to audio file for playback synchronization.
|
|
315
|
+
"""
|
|
316
|
+
if pmx_zip:
|
|
317
|
+
model_url = extract_pmx_zip(pmx_zip)
|
|
318
|
+
|
|
319
|
+
if audio_url and _is_absolute_file_path(audio_url):
|
|
320
|
+
audio_url = _ensure_servable(audio_url)
|
|
321
|
+
|
|
322
|
+
super().__init__(
|
|
323
|
+
value=value,
|
|
324
|
+
model_url=model_url,
|
|
325
|
+
physics_enabled=physics_enabled,
|
|
326
|
+
grey_mode=grey_mode,
|
|
327
|
+
camera_position=camera_position,
|
|
328
|
+
camera_rotation=camera_rotation,
|
|
329
|
+
audio_url=audio_url,
|
|
330
|
+
label=label,
|
|
331
|
+
every=every,
|
|
332
|
+
inputs=inputs,
|
|
333
|
+
show_label=show_label,
|
|
334
|
+
scale=scale,
|
|
335
|
+
min_width=min_width,
|
|
336
|
+
interactive=interactive,
|
|
337
|
+
visible=visible,
|
|
338
|
+
elem_id=elem_id,
|
|
339
|
+
elem_classes=elem_classes,
|
|
340
|
+
render=render,
|
|
341
|
+
key=key,
|
|
342
|
+
preserved_by_key=preserved_by_key,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# ── postprocess: Gradio's standard hook for path → URL ─────────
|
|
346
|
+
# Called on every value flowing to the frontend, including from gr.update().
|
|
347
|
+
# Copies absolute filesystem paths to .mmd_cache/ and returns /gradio_api/file=...
|
|
348
|
+
def postprocess(self, value: str | None) -> str | None:
|
|
349
|
+
value = super().postprocess(value)
|
|
350
|
+
if value and _is_absolute_file_path(value):
|
|
351
|
+
value = _ensure_servable(value)
|
|
352
|
+
return value
|
|
353
|
+
from typing import Callable, Literal, Sequence, Any, TYPE_CHECKING
|
|
354
|
+
from gradio.blocks import Block
|
|
355
|
+
if TYPE_CHECKING:
|
|
356
|
+
from gradio.components import Timer
|
|
357
|
+
from gradio.components.base import Component
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.11.29,<0.12"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gradio_mmd_viewer"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Gradio custom component: 3D MMD model viewer powered by Babylon.js + babylon-mmd"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.13"
|
|
12
|
+
authors = [{ name = "Winterreisender", email = "winterreisender@foxmail.com" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"gradio-custom-component",
|
|
15
|
+
"mmd",
|
|
16
|
+
"babylonjs",
|
|
17
|
+
"3d-viewer",
|
|
18
|
+
"miku-miku-dance",
|
|
19
|
+
]
|
|
20
|
+
# Add dependencies here
|
|
21
|
+
dependencies = [
|
|
22
|
+
"gradio>=6.0,<7.0",
|
|
23
|
+
"uvicorn>=0.51.0",
|
|
24
|
+
"websockets>=16.1",
|
|
25
|
+
]
|
|
26
|
+
classifiers = [
|
|
27
|
+
'Development Status :: 3 - Alpha',
|
|
28
|
+
'Operating System :: OS Independent',
|
|
29
|
+
'Programming Language :: Python :: 3',
|
|
30
|
+
'Programming Language :: Python :: 3 :: Only',
|
|
31
|
+
'Programming Language :: Python :: 3.13',
|
|
32
|
+
'Topic :: Scientific/Engineering',
|
|
33
|
+
'Topic :: Scientific/Engineering :: Visualization',
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# The repository and space URLs are optional, but recommended.
|
|
37
|
+
# Adding a repository URL will create a badge in the auto-generated README that links to the repository.
|
|
38
|
+
# Adding a space URL will create a badge in the auto-generated README that links to the space.
|
|
39
|
+
# This will make it easy for people to find your deployed demo or source code when they
|
|
40
|
+
# encounter your project in the wild.
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
repository = "https://github.com/Winterreisender/gradio-mmd-viewer"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
dev = ["build"]
|
|
47
|
+
|
|
48
|
+
[tool.uv.build-backend]
|
|
49
|
+
module-name = "gradio_mmd_viewer"
|
|
50
|
+
module-root = "backend"
|
|
51
|
+
|
|
52
|
+
[dependency-groups]
|
|
53
|
+
dev = [
|
|
54
|
+
"build>=1.5.0",
|
|
55
|
+
]
|