dazpy 2.6.0__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.
- dazpy-2.6.0/PKG-INFO +2642 -0
- dazpy-2.6.0/README.md +2627 -0
- dazpy-2.6.0/dazpy/__init__.py +158 -0
- dazpy-2.6.0/dazpy/_animation.py +402 -0
- dazpy-2.6.0/dazpy/_batch.py +107 -0
- dazpy-2.6.0/dazpy/_bone.py +87 -0
- dazpy-2.6.0/dazpy/_camera.py +188 -0
- dazpy-2.6.0/dazpy/_client.py +452 -0
- dazpy-2.6.0/dazpy/_element.py +97 -0
- dazpy-2.6.0/dazpy/_geometry.py +547 -0
- dazpy-2.6.0/dazpy/_interaction.py +2003 -0
- dazpy-2.6.0/dazpy/_light.py +87 -0
- dazpy-2.6.0/dazpy/_material.py +105 -0
- dazpy-2.6.0/dazpy/_modifier.py +40 -0
- dazpy-2.6.0/dazpy/_morph.py +43 -0
- dazpy-2.6.0/dazpy/_node.py +556 -0
- dazpy-2.6.0/dazpy/_polling.py +59 -0
- dazpy-2.6.0/dazpy/_pose.py +343 -0
- dazpy-2.6.0/dazpy/_property.py +101 -0
- dazpy-2.6.0/dazpy/_render.py +185 -0
- dazpy-2.6.0/dazpy/_render_api.py +337 -0
- dazpy-2.6.0/dazpy/_result.py +24 -0
- dazpy-2.6.0/dazpy/_scene.py +658 -0
- dazpy-2.6.0/dazpy/_script_builder.py +64 -0
- dazpy-2.6.0/dazpy/_skeleton.py +683 -0
- dazpy-2.6.0/dazpy/_timeline.py +65 -0
- dazpy-2.6.0/dazpy/_undo.py +40 -0
- dazpy-2.6.0/dazpy/_viewport.py +190 -0
- dazpy-2.6.0/dazpy/exceptions.py +77 -0
- dazpy-2.6.0/dazpy/math3.py +611 -0
- dazpy-2.6.0/dazpy/py.typed +0 -0
- dazpy-2.6.0/dazpy.egg-info/PKG-INFO +2642 -0
- dazpy-2.6.0/dazpy.egg-info/SOURCES.txt +44 -0
- dazpy-2.6.0/dazpy.egg-info/dependency_links.txt +1 -0
- dazpy-2.6.0/dazpy.egg-info/requires.txt +5 -0
- dazpy-2.6.0/dazpy.egg-info/top_level.txt +1 -0
- dazpy-2.6.0/pyproject.toml +31 -0
- dazpy-2.6.0/setup.cfg +4 -0
- dazpy-2.6.0/tests/test_api.py +751 -0
- dazpy-2.6.0/tests/test_comfyui_client.py +163 -0
- dazpy-2.6.0/tests/test_comfyui_enhance_integration.py +124 -0
- dazpy-2.6.0/tests/test_dazpy.py +3936 -0
- dazpy-2.6.0/tests/test_dazpy_integration.py +1903 -0
- dazpy-2.6.0/tests/test_file_watcher.py +113 -0
- dazpy-2.6.0/tests/test_performance.py +413 -0
- dazpy-2.6.0/tests/test_workflow_builder.py +94 -0
dazpy-2.6.0/PKG-INFO
ADDED
|
@@ -0,0 +1,2642 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dazpy
|
|
3
|
+
Version: 2.6.0
|
|
4
|
+
Summary: Python SDK for the DAZ Studio Script Server
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/bluemoonfoundry/daz-script-server
|
|
7
|
+
Project-URL: Repository, https://github.com/bluemoonfoundry/daz-script-server
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/bluemoonfoundry/daz-script-server/issues
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: requests>=2.28
|
|
12
|
+
Provides-Extra: docs
|
|
13
|
+
Requires-Dist: sphinx>=7.0; extra == "docs"
|
|
14
|
+
Requires-Dist: sphinx-rtd-theme>=2.0; extra == "docs"
|
|
15
|
+
|
|
16
|
+
# DazScript Server
|
|
17
|
+
|
|
18
|
+
**Version 2.6.0** | DAZ Studio 4.5+ | DAZ Studio 6.25+ | Windows & macOS
|
|
19
|
+
|
|
20
|
+
[](https://bluemoonfoundry.github.io/daz-script-server/)
|
|
21
|
+
[](https://bluemoonfoundry.github.io/daz-script-server/api-reference/)
|
|
22
|
+
|
|
23
|
+
A production-ready DAZ Studio plugin that embeds a secure HTTP server inside DAZ Studio, enabling remote execution of DazScript via HTTP POST requests (or an optional Python library that wraps the interface and adds additional features) with JSON responses. Control DAZ Studio programmatically from external tools, automation scripts, and custom applications.
|
|
24
|
+
|
|
25
|
+
> [!NOTE]
|
|
26
|
+
> As of v2.6.0, plugins for DS4 and DS6 on Windows, MacOS Intel, and MacOS Apple Silicon are included as part of the standard release artifacts. Remember that if you want to use a newer version of the plugin you must remove any old DLL or dylib file first!
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## 🚀 Quick Start
|
|
30
|
+
|
|
31
|
+
**Already have the plugin installed?**
|
|
32
|
+
|
|
33
|
+
1. Open DAZ Studio → **Window → Panes → Daz Script Server**
|
|
34
|
+
2. Click **Start Server** (default: `127.0.0.1:18811`)
|
|
35
|
+
3. Click **Copy** to copy your API token (optional, but recommended for public environments)
|
|
36
|
+
|
|
37
|
+
**Option A — dazpy Python SDK (recommended):**
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install dazpy
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import time
|
|
45
|
+
from dazpy import DazClient, DazScene
|
|
46
|
+
|
|
47
|
+
client = DazClient() # auto-loads token from ~/.daz3d/dazscriptserver_token.txt
|
|
48
|
+
scene = DazScene(client)
|
|
49
|
+
|
|
50
|
+
print(scene.num_nodes(), "nodes in scene")
|
|
51
|
+
|
|
52
|
+
figure = scene.find_skeleton_by_label("Genesis 9")
|
|
53
|
+
|
|
54
|
+
bones = figure.bones()
|
|
55
|
+
print([b._identifier.value for b in bones if "neck" in b._identifier.value.lower()])
|
|
56
|
+
|
|
57
|
+
rots=[0,4,10,15,20]
|
|
58
|
+
|
|
59
|
+
for rot in rots:
|
|
60
|
+
print (f"Set rotation {rot}")
|
|
61
|
+
figure.find_bone("neck1").set_local_rotation(0, rot, 0)
|
|
62
|
+
time.sleep(1)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Option B — raw HTTP (any language):**
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import requests
|
|
69
|
+
|
|
70
|
+
response = requests.post(
|
|
71
|
+
"http://127.0.0.1:18811/execute",
|
|
72
|
+
json={"script": "(function(){ return 'Hello there from Daz Studio!'; })()"}
|
|
73
|
+
)
|
|
74
|
+
print(response.json())
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Need to get the plugin?** Jump to [Getting the Plugin](#getting-the-plugin) below.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 📋 Table of Contents
|
|
82
|
+
|
|
83
|
+
### Getting Started
|
|
84
|
+
- [Quick Start](#-quick-start)
|
|
85
|
+
- [Why This Exists](#why-this-exists)
|
|
86
|
+
- [What's New in v2.6.0](#whats-new-in-v260)
|
|
87
|
+
- [What's New in v2.5.0](#whats-new-in-v250)
|
|
88
|
+
- [What's New in v2.4.0](#whats-new-in-v240)
|
|
89
|
+
- [What's New in v2.3.0](#whats-new-in-v230)
|
|
90
|
+
- [What's New in v2.2.0](#whats-new-in-v220)
|
|
91
|
+
- [What's New in v2.0.0](#whats-new-in-v200)
|
|
92
|
+
- [What's New in v1.3.0](#whats-new-in-v130)
|
|
93
|
+
- [What's New in v1.2.0](#whats-new-in-v120)
|
|
94
|
+
- [Requirements](#requirements)
|
|
95
|
+
- [Getting the Plugin](#getting-the-plugin)
|
|
96
|
+
|
|
97
|
+
### dazpy Python SDK
|
|
98
|
+
- [Overview](#-dazpy-python-sdk)
|
|
99
|
+
- [Installation](#installation)
|
|
100
|
+
- [Jupyter Notebook](#jupyter-notebook)
|
|
101
|
+
- [Connecting to DAZ Studio](#connecting-to-daz-studio)
|
|
102
|
+
- [Scene Graph](#scene-graph)
|
|
103
|
+
- [Figures — Skeletons and Bones](#figures--skeletons-and-bones)
|
|
104
|
+
- [Morphs](#morphs)
|
|
105
|
+
- [Materials](#materials)
|
|
106
|
+
- [Cameras and Lights](#cameras-and-lights)
|
|
107
|
+
- [Scene I/O and Timeline](#scene-io-and-timeline)
|
|
108
|
+
- [Geometry](#geometry)
|
|
109
|
+
- [Advanced: Batch, Undo, Async](#advanced-batch-undo-and-async)
|
|
110
|
+
- [Error Handling](#error-handling)
|
|
111
|
+
- [Testing dazpy](#testing-dazpy)
|
|
112
|
+
|
|
113
|
+
### Using the Plugin
|
|
114
|
+
- [Starting the Server](#starting-the-server)
|
|
115
|
+
- [Configuration](#-configuration)
|
|
116
|
+
- [API Reference](#-api-reference)
|
|
117
|
+
- [Script Registry](#-script-registry)
|
|
118
|
+
- [Async Operations](#-async-operations)
|
|
119
|
+
- [Scene Events (SSE)](#-scene-events-sse)
|
|
120
|
+
- [Client Examples](#-client-examples)
|
|
121
|
+
|
|
122
|
+
### Security & Best Practices
|
|
123
|
+
- [Security Features](#-security-features)
|
|
124
|
+
- [Security Best Practices](#-security-best-practices)
|
|
125
|
+
- [Troubleshooting](#-troubleshooting)
|
|
126
|
+
|
|
127
|
+
### Advanced Topics
|
|
128
|
+
- [Writing Scripts](#writing-scripts)
|
|
129
|
+
- [Performance Tuning](#performance-tuning)
|
|
130
|
+
- [Integration Patterns](#integration-patterns)
|
|
131
|
+
- [Reverse Proxy Setup](#reverse-proxy-setup-https)
|
|
132
|
+
|
|
133
|
+
### Reference
|
|
134
|
+
- [FAQ](#-frequently-asked-questions)
|
|
135
|
+
- [Contributing](#contributing)
|
|
136
|
+
- [License](#license--attribution)
|
|
137
|
+
|
|
138
|
+
### Additional Documentation
|
|
139
|
+
- [dazpy SDK Docs](https://bluemoonfoundry.github.io/daz-script-server/) — hosted on GitHub Pages
|
|
140
|
+
- [HTTP API Reference](https://bluemoonfoundry.github.io/daz-script-server/api-reference/) — hosted on GitHub Pages
|
|
141
|
+
- [OpenAPI Specification](openapi.yaml)
|
|
142
|
+
- [Architecture](ARCHITECTURE.md)
|
|
143
|
+
- [Migration Guide (v1.x → v2.0)](MIGRATION.md)
|
|
144
|
+
- [Changelog](CHANGELOG.md)
|
|
145
|
+
- [Contributing Guide](CONTRIBUTING.md)
|
|
146
|
+
- [Interaction Pose Roadmap](INTERACTION_POSE_ROADMAP.md)
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## Why This Exists
|
|
151
|
+
|
|
152
|
+
DAZ Studio is powerful for 3D content creation, but automation is limited to manually running scripts. This plugin solves that by exposing REST endpoints (HTTP) for remote clients to, among other things, submit inline scripts to execute, which can return log output lines and a return object as JSON.
|
|
153
|
+
|
|
154
|
+
**What You Can Do:**
|
|
155
|
+
- **Remote Automation** - Control DAZ Studio from Python, web apps, CI/CD pipelines
|
|
156
|
+
- **Programmatic Access** - Build custom tools that interact with scenes, assets, and rendering
|
|
157
|
+
- **Integration** - Connect to game engines, asset pipelines, batch processing systems
|
|
158
|
+
- **API-First Development** - Treat DAZ Studio as a service with HTTP APIs
|
|
159
|
+
|
|
160
|
+
**Common Use Cases:**
|
|
161
|
+
- Batch rendering and asset processing
|
|
162
|
+
- Asset management system integration
|
|
163
|
+
- Automated scene generation and testing
|
|
164
|
+
- Custom web-based controllers
|
|
165
|
+
- CI/CD pipelines for 3D content validation
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## What's New in v2.6.0
|
|
170
|
+
|
|
171
|
+
### 💾 Scene Save Copy — `POST /scene/save-copy` + `DazScene.save_copy()`
|
|
172
|
+
|
|
173
|
+
Save the current scene to a new path without changing the scene's internal
|
|
174
|
+
filename pointer or dirty flag — the programmatic equivalent of a
|
|
175
|
+
**"Save a Copy As…"** menu option.
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from dazpy import DazClient, DazScene
|
|
179
|
+
|
|
180
|
+
client = DazClient()
|
|
181
|
+
scene = DazScene(client)
|
|
182
|
+
|
|
183
|
+
result = scene.save_copy("C:/backups/scene_v2.duf")
|
|
184
|
+
print(result["method"]) # "copy", "serialize", or "serialize+restore"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
For scenes with no unsaved changes the plugin uses `QFile::copy()` — a pure
|
|
188
|
+
file-system copy that produces a byte-identical file with zero DAZ Studio state
|
|
189
|
+
change. For dirty scenes it serialises via `Scene.saveScene()` and restores
|
|
190
|
+
the original filename immediately. The `method` field in the response tells
|
|
191
|
+
you which strategy was used.
|
|
192
|
+
|
|
193
|
+
See `docs/examples/fundamentals/scene_save_copy.py` for a complete example
|
|
194
|
+
with `--compare` and `--dry-run` options.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## What's New in v2.5.0
|
|
199
|
+
|
|
200
|
+
### 🤝 Interaction Posing — Multi-Figure Poses and IK Alignment
|
|
201
|
+
|
|
202
|
+
The dazpy SDK now includes a full **interaction posing system** for placing two
|
|
203
|
+
figures together in physically plausible poses (seated, touching, kissing, combat
|
|
204
|
+
stances, etc.) and aligning individual limbs to world-space targets using an
|
|
205
|
+
iterative IK solver.
|
|
206
|
+
|
|
207
|
+
**Preset recipe builders** generate a ready-to-apply `InteractionRecipe` from a
|
|
208
|
+
pair of figure labels:
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from dazpy import DazClient, DazScene
|
|
212
|
+
from dazpy._interaction import build_sit_recipe, build_touch_recipe
|
|
213
|
+
|
|
214
|
+
client = DazClient()
|
|
215
|
+
scene = DazScene(client)
|
|
216
|
+
|
|
217
|
+
recipe = build_sit_recipe("BobG8", "AliceG8")
|
|
218
|
+
scene.apply_interaction_recipe_to_scene(recipe)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Available presets: `build_sit_recipe`, `build_touch_recipe`, `build_kiss_recipe`,
|
|
222
|
+
`build_fight_recipe`. All presets produce an `InteractionRecipe` that can be
|
|
223
|
+
serialised to JSON, stored, and replayed.
|
|
224
|
+
|
|
225
|
+
**IK limb alignment** moves a hand or foot end-effector to a world-space target
|
|
226
|
+
using a Jacobian-based iterative solver running entirely inside a single batched
|
|
227
|
+
HTTP call sequence:
|
|
228
|
+
|
|
229
|
+
```python
|
|
230
|
+
from dazpy._interaction import align_hand_target, align_foot_target
|
|
231
|
+
|
|
232
|
+
skel = scene.find_skeleton("BobG8")
|
|
233
|
+
result = align_hand_target(skel, target_position=(12.0, 95.0, 8.0))
|
|
234
|
+
print(f"converged={result.converged} final_error={result.final_error:.3f} cm")
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Bulk scene snapshot** fetches every skeleton's bone world positions in a single
|
|
238
|
+
HTTP call, avoiding per-bone round-trips when building rig data:
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
snapshot = scene.scene_snapshot()
|
|
242
|
+
profiles = build_rig_profiles_from_snapshot(snapshot)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
**Body measurement accuracy improvements** in `docs/examples/body_measurements.py`:
|
|
246
|
+
|
|
247
|
+
- Slices now use centroid-weighted torso loop selection to filter arm cross-sections, correcting a ~70 cm overestimate on A-pose figures.
|
|
248
|
+
- The heuristic fallback for unlabelled figures (e.g. a character named
|
|
249
|
+
`"MadisonG9"`) now uses robust outlier rejection before selecting the peak
|
|
250
|
+
perimeter slice.
|
|
251
|
+
- Pass `--figure-type G9F` (or `G9M`, `G8F`, etc.) to force a calibration entry
|
|
252
|
+
when the figure's scene label doesn't contain a gender keyword.
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## What's New in v2.4.0
|
|
257
|
+
|
|
258
|
+
### 🎬 Render Endpoints — Trigger and Track DAZ Studio Renders
|
|
259
|
+
|
|
260
|
+
Three new HTTP endpoints let you submit renders, stream live progress, and batch
|
|
261
|
+
render variants — all without blocking the server.
|
|
262
|
+
|
|
263
|
+
**`POST /render`** — submit a render job and get back a `request_id`:
|
|
264
|
+
|
|
265
|
+
```python
|
|
266
|
+
from dazpy import DazClient, render, RenderVariant, FigureMorphs
|
|
267
|
+
|
|
268
|
+
client = DazClient()
|
|
269
|
+
|
|
270
|
+
# Simple render with default settings
|
|
271
|
+
result = render(client, width=1920, height=1080)
|
|
272
|
+
print(result.output_path)
|
|
273
|
+
|
|
274
|
+
# Render with morph overrides
|
|
275
|
+
result = render(client, width=1920, height=1080,
|
|
276
|
+
figure_morphs=[FigureMorphs("Genesis 9", {"eCTRLSmileSimple": 1.0})])
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**`POST /render/batch`** — submit multiple variants in one request:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
from dazpy import render_variants, RenderVariant, FigureMorphs
|
|
283
|
+
|
|
284
|
+
variants = [
|
|
285
|
+
RenderVariant(label="neutral", figure_morphs=[FigureMorphs("Genesis 9", {})]),
|
|
286
|
+
RenderVariant(label="smile", figure_morphs=[FigureMorphs("Genesis 9", {"eCTRLSmileSimple": 1.0})]),
|
|
287
|
+
RenderVariant(label="frown", figure_morphs=[FigureMorphs("Genesis 9", {"eCTRLFrownSimple": 0.8})]),
|
|
288
|
+
]
|
|
289
|
+
results = render_variants(client, variants, width=1920, height=1080)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**`GET /render/:id/progress`** — SSE stream of render progress events:
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
data: {"stage":"rendering","progress":0.42,"elapsed_ms":4200}
|
|
296
|
+
data: {"stage":"complete","progress":1.0,"output_path":"/path/to/render.png","elapsed_ms":9800}
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
**`POST /render/:id/cancel`** — cancel a queued or running render job:
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
# Submit without waiting, cancel later
|
|
303
|
+
job = render(client, r"C:\tmp\out.png", width=1920, height=1080, wait=False)
|
|
304
|
+
# ...
|
|
305
|
+
client.cancel_render(job.request_id)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Returns 400 if the `request_id` belongs to a non-render job, so you get a clear
|
|
309
|
+
error rather than silently cancelling the wrong request.
|
|
310
|
+
|
|
311
|
+
### 🎥 Scene Camera and Light Discovery
|
|
312
|
+
|
|
313
|
+
Two new `DazScene` methods make it easy to look up cameras and lights by label —
|
|
314
|
+
the same label string the `render()` `camera` parameter accepts:
|
|
315
|
+
|
|
316
|
+
```python
|
|
317
|
+
from dazpy import DazClient, DazScene
|
|
318
|
+
from dazpy._render_api import render
|
|
319
|
+
|
|
320
|
+
client = DazClient()
|
|
321
|
+
scene = DazScene(client)
|
|
322
|
+
|
|
323
|
+
cam = scene.find_camera_by_label("Camera 1")
|
|
324
|
+
print(cam.focal_length, cam.pixels_width)
|
|
325
|
+
|
|
326
|
+
# Use the found camera name directly in a render
|
|
327
|
+
render(client, r"C:\tmp\out.png", camera="Camera 1", width=1920, height=1080)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### ↩️ Programmatic Undo / Redo
|
|
331
|
+
|
|
332
|
+
Step the DAZ Studio undo stack from Python:
|
|
333
|
+
|
|
334
|
+
```python
|
|
335
|
+
scene.set_frame(10)
|
|
336
|
+
scene.undo_last() # step back (Ctrl+Z equivalent)
|
|
337
|
+
scene.redo_last() # step forward (Ctrl+Y equivalent)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
`undo_last()` / `redo_last()` step the global stack directly. Use the existing
|
|
341
|
+
`scene.undo("label")` context manager when you want to group a series of changes
|
|
342
|
+
into a single undoable operation.
|
|
343
|
+
|
|
344
|
+
### 🔌 Companion Plugin Route Registration
|
|
345
|
+
|
|
346
|
+
Third-party plugins can now register their own HTTP routes into the DazScript
|
|
347
|
+
Server. Routes are resolved via a `DzScriptServerPane` pointer published on
|
|
348
|
+
`qApp`, so companion plugins discovered at DAZ Studio startup are automatically
|
|
349
|
+
wired in. Registered routes support path parameters (e.g. `/export/:id/status`).
|
|
350
|
+
|
|
351
|
+
### 🐛 Bug Fixes
|
|
352
|
+
|
|
353
|
+
- Fixed CRT heap mismatch crash when a catch-all dispatcher routed to a
|
|
354
|
+
companion plugin (mixed MSVC runtime allocator boundary).
|
|
355
|
+
- Fixed companion plugin routes silently dropped when registered after the
|
|
356
|
+
server had already started listening.
|
|
357
|
+
- Fixed data race in the catch-all route dispatcher under concurrent requests.
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## What's New in v2.3.0
|
|
362
|
+
|
|
363
|
+
### 🔔 Scene Events — Real-Time Push Notifications via SSE
|
|
364
|
+
|
|
365
|
+
Clients can now subscribe to live DAZ Studio scene changes over a persistent HTTP connection using **Server-Sent Events (SSE)**. No more polling for scene state.
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
import requests, json
|
|
369
|
+
|
|
370
|
+
token = open(os.path.expanduser("~/.daz3d/dazscriptserver_token.txt")).read().strip()
|
|
371
|
+
|
|
372
|
+
with requests.get(
|
|
373
|
+
"http://127.0.0.1:18811/scene/events",
|
|
374
|
+
headers={"X-API-Token": token},
|
|
375
|
+
stream=True
|
|
376
|
+
) as r:
|
|
377
|
+
for line in r.iter_lines():
|
|
378
|
+
if line.startswith(b"data: "):
|
|
379
|
+
event = json.loads(line[6:])
|
|
380
|
+
print(event["type"], event["data"])
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
**Event types:** `node.added`, `node.removed`, `skeleton.added/removed`, `light.added/removed`, `camera.added/removed`, `selection.list_changed`, `selection.primary_changed`, `scene.loading`, `scene.loaded`, `scene.saving`, `scene.saved`, `scene.clear_starting`, `scene.cleared`, `time.changed`, `playback.started`, `playback.finished`, `render.started`, `render.finished`
|
|
384
|
+
|
|
385
|
+
**Filter to only the categories you need:**
|
|
386
|
+
```
|
|
387
|
+
GET /scene/events?filter=node,selection,scene
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
All auth, IP whitelist, and security checks apply. The connection stays open until the client disconnects or the server stops. See [Scene Events (SSE)](#-scene-events-sse) for full documentation.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## What's New in v2.2.0
|
|
395
|
+
|
|
396
|
+
### 🦴 DazPose — Snapshot and Restore Poses
|
|
397
|
+
|
|
398
|
+
New module **`dazpy.DazPose`** captures a full skeleton pose in one call and
|
|
399
|
+
restores it later, with linear interpolation between any two stored poses.
|
|
400
|
+
|
|
401
|
+
```python
|
|
402
|
+
from dazpy import DazScene, DazPose
|
|
403
|
+
|
|
404
|
+
scene = DazScene()
|
|
405
|
+
figure = scene.find_skeleton_by_label("Genesis 9")
|
|
406
|
+
|
|
407
|
+
neutral = DazPose.capture(figure) # snapshot current pose
|
|
408
|
+
# ... dial morphs, animate, etc. ...
|
|
409
|
+
DazPose.blend(neutral, other_pose, t=0.5).apply(figure) # 50 % mix
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
### 🎞 DazAnimation — Keyframe Read / Write
|
|
413
|
+
|
|
414
|
+
New module **`dazpy.DazAnimation`** exposes per-bone rotation and translation
|
|
415
|
+
tracks, timeline range queries, frame stepping, and pose baking.
|
|
416
|
+
|
|
417
|
+
```python
|
|
418
|
+
from dazpy import DazAnimation
|
|
419
|
+
|
|
420
|
+
anim = DazAnimation(figure)
|
|
421
|
+
print(anim.frame_range()) # (start, end)
|
|
422
|
+
anim.bake_pose_to_keyframes(start=0, end=60)
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### 📐 math3 — Vec3, Quat, BoundingBox
|
|
426
|
+
|
|
427
|
+
New module **`dazpy.math3`** provides lightweight value types returned throughout
|
|
428
|
+
the SDK: `Vec3` (positions, translations), `Quat` (rotations), and `BoundingBox`
|
|
429
|
+
(geometry bounds). All support arithmetic operators and round-trip through JSON.
|
|
430
|
+
|
|
431
|
+
### 🌐 Posed Vertex Export & USD Scene Export
|
|
432
|
+
|
|
433
|
+
**`DazGeometry.vertex_positions_posed()`** returns world-space deformed vertex
|
|
434
|
+
positions with skinning and morphs already applied — enabling downstream export
|
|
435
|
+
pipelines to read the final mesh without re-solving the rig.
|
|
436
|
+
|
|
437
|
+
New example **`docs/examples/scene_to_usd.py`** uses this to export a full live
|
|
438
|
+
DAZ Studio scene to Pixar USD: posed meshes, cameras, lights, PBR materials, and
|
|
439
|
+
strand-based hair as `UsdGeom.BasisCurves`. Pass `--morphs` to write blend
|
|
440
|
+
shapes as `UsdSkel` targets.
|
|
441
|
+
|
|
442
|
+
```bash
|
|
443
|
+
python scene_to_usd.py --output scene.usda
|
|
444
|
+
python scene_to_usd.py --output scene.usda --morphs
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### 🏃 BVH Motion Capture Support
|
|
448
|
+
|
|
449
|
+
Three new interoperable example scripts:
|
|
450
|
+
|
|
451
|
+
| Script | Purpose |
|
|
452
|
+
|---|---|
|
|
453
|
+
| `bvh_import.py` | Parse a `.bvh` file and apply each frame to a DAZ skeleton |
|
|
454
|
+
| `bvh_discover.py` | Print a figure's bone hierarchy to help build bone maps |
|
|
455
|
+
| `bvh_bone_maps.py` | Canonical BVH ↔ DAZ bone-name tables for G8 / G9 |
|
|
456
|
+
|
|
457
|
+
### 🗂 New Examples
|
|
458
|
+
|
|
459
|
+
Four additional examples expanding SDK coverage:
|
|
460
|
+
|
|
461
|
+
| Script | What it shows |
|
|
462
|
+
|---|---|
|
|
463
|
+
| `animation_mixing.py` | Blend two stored poses at a configurable weight |
|
|
464
|
+
| `batch_operations.py` | Morph dials, material swaps, and camera moves in one batched request |
|
|
465
|
+
| `geometry_analysis.py` | Mesh vertex count, bounding box, and posed positions |
|
|
466
|
+
| `keyframe_baking.py` | Sample procedural animation and write explicit rotation keyframes |
|
|
467
|
+
|
|
468
|
+
### 🧪 Test Suite
|
|
469
|
+
|
|
470
|
+
`tests_dazpy.py` (unit) and `tests_dazpy_integration.py` (requires a live DAZ
|
|
471
|
+
Studio instance) now ship in the repo root.
|
|
472
|
+
|
|
473
|
+
### 📖 Docs & Ergonomics
|
|
474
|
+
|
|
475
|
+
- API reference pages for `DazPose`, `DazAnimation`, `Vec3`, `Quat`, and `BoundingBox`
|
|
476
|
+
- Every script in `docs/examples/` has an `if __name__ == "__main__":` guard and
|
|
477
|
+
argparse `--help`, making all examples safe to import and self-documenting
|
|
478
|
+
|
|
479
|
+
---
|
|
480
|
+
|
|
481
|
+
## What's New in v2.0.0
|
|
482
|
+
|
|
483
|
+
v2.0 is a **backward-compatible** internal rewrite focused on correctness and reliability.
|
|
484
|
+
The HTTP API, authentication mechanism, and all settings are unchanged.
|
|
485
|
+
|
|
486
|
+
### dazpy Python SDK
|
|
487
|
+
|
|
488
|
+
A full-featured Python SDK ships alongside the plugin, installable as a wheel from the
|
|
489
|
+
release page or via `pip install dazpy`. It exposes DAZ Studio's scene graph as typed
|
|
490
|
+
Python objects: `DazScene`, `DazSkeleton`, `DazBone`, `DazMaterial`, `DazMorph`, and more.
|
|
491
|
+
See [dazpy Python SDK](#-dazpy-python-sdk) below for the complete reference.
|
|
492
|
+
|
|
493
|
+
### Bug Fixes
|
|
494
|
+
|
|
495
|
+
- **Concurrent request race condition fixed** — Under heavy load, more requests than the
|
|
496
|
+
configured maximum could slip through. The active-request counter is now atomically
|
|
497
|
+
managed under a `QMutex`.
|
|
498
|
+
- **DzScript memory safety** — Script objects are always destroyed on the main Qt thread,
|
|
499
|
+
eliminating intermittent crashes from cross-thread deletion.
|
|
500
|
+
- **Signal/slot leak** — The `print()` capture connection is now always explicitly
|
|
501
|
+
disconnected on early-return paths (auth failure, rate limit, etc.), preventing stale
|
|
502
|
+
output from appearing in unrelated responses.
|
|
503
|
+
|
|
504
|
+
### Architecture
|
|
505
|
+
|
|
506
|
+
- Extracted `AuthenticationService`, `RateLimiterService`, `IPWhitelistService`,
|
|
507
|
+
`MetricsCollector`, and `AsyncRequestManager` from the monolithic pane class
|
|
508
|
+
- `RequestValidator` and `RequestProcessor` replace inline dispatch logic
|
|
509
|
+
- `ServerSettings` / `ServerConfig` centralise all defaults and magic numbers
|
|
510
|
+
|
|
511
|
+
### New Documentation
|
|
512
|
+
|
|
513
|
+
- `openapi.yaml` — machine-readable OpenAPI 3.0 specification
|
|
514
|
+
- `ARCHITECTURE.md` — component and threading diagrams (Mermaid)
|
|
515
|
+
- `MIGRATION.md` — step-by-step upgrade guide from v1.x
|
|
516
|
+
- `CHANGELOG.md` — full version history
|
|
517
|
+
- `CONTRIBUTING.md` — developer guide
|
|
518
|
+
|
|
519
|
+
See the [full migration guide](MIGRATION.md) for upgrade steps and rollback instructions.
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
523
|
+
## What's New in v1.3.0
|
|
524
|
+
|
|
525
|
+
### ⚡ Async Script Execution
|
|
526
|
+
|
|
527
|
+
Long-running operations (renders, exports, batch jobs) no longer need to block the HTTP connection:
|
|
528
|
+
|
|
529
|
+
- **`POST /execute/async`** — Submit any inline script asynchronously; returns a `request_id` immediately
|
|
530
|
+
- **`POST /scripts/:id/async`** — Submit a registered script asynchronously
|
|
531
|
+
- **`GET /requests/:id/status`** — Poll for progress (`queued`, `running`, `completed`, `failed`, `cancelled`)
|
|
532
|
+
- **`GET /requests/:id/result`** — Fetch the final result; supports `?wait=true` to long-poll until complete
|
|
533
|
+
- **`DELETE /requests/:id`** — Cancel a queued or running request
|
|
534
|
+
- **`GET /requests`** — List all active and recently completed requests
|
|
535
|
+
|
|
536
|
+
**Cancellation:** Queued requests are cancelled immediately. Running requests set a cancel flag and call `killRender()` — DAZ Studio honours the flag when the script returns.
|
|
537
|
+
|
|
538
|
+
**TTL:** Completed, failed, and cancelled requests are automatically purged after 1 hour (cleanup timer fires every 5 minutes).
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## What's New in v1.2.0
|
|
543
|
+
|
|
544
|
+
### 🔒 Security Enhancements
|
|
545
|
+
- **IP Whitelist** - Restrict access to specific IP addresses
|
|
546
|
+
- **Per-IP Rate Limiting** - Prevent brute force attacks with sliding window limits
|
|
547
|
+
- **Configurable Limits** - Adjust concurrent requests, body size, and script length
|
|
548
|
+
|
|
549
|
+
### 📦 Script Registry
|
|
550
|
+
- **Register Once, Call Many** - Upload scripts by name, execute by ID without retransmission
|
|
551
|
+
- **Session-Based Storage** - In-memory registry with register, list, execute, and delete endpoints
|
|
552
|
+
- **Auto-Recovery** - Returns 404 on stale IDs so clients can re-register after restarts
|
|
553
|
+
|
|
554
|
+
### ✨ Usability Improvements
|
|
555
|
+
- **Active Request Counter** - Real-time display of concurrent requests (X/max)
|
|
556
|
+
- **Auto-Start Option** - Start server automatically when pane opens
|
|
557
|
+
- **Better Error Messages** - Descriptive errors with actionable guidance
|
|
558
|
+
- **More Examples** - Added JavaScript/Node.js and PowerShell client examples
|
|
559
|
+
|
|
560
|
+
### 💾 Persistent Configuration
|
|
561
|
+
All settings (security, limits, monitoring) are saved via QSettings and restored between sessions.
|
|
562
|
+
|
|
563
|
+
---
|
|
564
|
+
|
|
565
|
+
## Requirements
|
|
566
|
+
|
|
567
|
+
- **DAZ Studio 4.5+** (for running the plugin)
|
|
568
|
+
- **DAZ Studio 4.5+ SDK** (for building from source)
|
|
569
|
+
- **CMake 3.5+**
|
|
570
|
+
- **Compiler:**
|
|
571
|
+
- Windows: Visual Studio 2019 or 2022 (MSVC)
|
|
572
|
+
- macOS: Xcode / clang with libc++
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## 🐍 dazpy Python SDK
|
|
577
|
+
|
|
578
|
+
`dazpy` is a Python SDK that drives DAZ Studio through the Script Server.
|
|
579
|
+
It exposes the DAZ Studio scene graph as typed Python objects so you can write
|
|
580
|
+
automation code without authoring DazScript by hand.
|
|
581
|
+
|
|
582
|
+
### Installation
|
|
583
|
+
|
|
584
|
+
Download the `.whl` file from the [latest release](https://github.com/bluemoonfoundry/daz-script-server/releases/latest) and install it:
|
|
585
|
+
|
|
586
|
+
```bash
|
|
587
|
+
pip install dazpy-2.6.0-py3-none-any.whl
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Or install directly from the repo for development:
|
|
591
|
+
|
|
592
|
+
```bash
|
|
593
|
+
pip install -e .
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
**Requirements:** Python 3.10+, `requests` (installed automatically).
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
### Jupyter Notebook
|
|
601
|
+
|
|
602
|
+
The repo ships an interactive Jupyter notebook (`notebooks/dazpy_intro.ipynb`) that
|
|
603
|
+
covers the full dazpy API with runnable examples. Launcher scripts handle installing
|
|
604
|
+
Jupyter and opening the notebook in one step.
|
|
605
|
+
|
|
606
|
+
**Prerequisites:** DAZ Studio must be running with the DazScriptServer plugin active before opening the notebook.
|
|
607
|
+
|
|
608
|
+
**Launch (macOS / Linux / Git Bash on Windows):**
|
|
609
|
+
|
|
610
|
+
```bash
|
|
611
|
+
./notebook.sh
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
**Launch (Windows PowerShell):**
|
|
615
|
+
|
|
616
|
+
```powershell
|
|
617
|
+
.\notebook.ps1
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
Both scripts install `dazpy` (editable) and `notebook` if they are not already present,
|
|
621
|
+
then open Jupyter in the `notebooks/` directory.
|
|
622
|
+
|
|
623
|
+
**Notebook sections:**
|
|
624
|
+
|
|
625
|
+
| Section | What it covers |
|
|
626
|
+
|---|---|
|
|
627
|
+
| 1. Server health | `client.health()` — verify the plugin is reachable |
|
|
628
|
+
| 2. Scene overview | `scene.num_nodes()`, `scene.nodes()` — list everything in the scene |
|
|
629
|
+
| 3. Find a figure / read a bone | `find_skeleton_by_label()`, `find_bone()`, `bone.local_euler` |
|
|
630
|
+
| 4. Raw DazScript | `client.execute()` — run arbitrary DazScript when the SDK doesn't cover it |
|
|
631
|
+
| 5. Error handling | Full exception hierarchy with `try/except` examples |
|
|
632
|
+
| 6. API browser | `dir(obj)` one-liner to list any object's public API surface |
|
|
633
|
+
| 7. Jupyter introspection | `obj?` / `obj??` inline help for any dazpy object |
|
|
634
|
+
| 8. Interactive bone rotator | `ipywidgets` sliders driving `bone.set_local_rotation()` in real time |
|
|
635
|
+
| 9. Morph / property explorer | List, search, and set morphs with `morph_values()` / `set_morph_values()` |
|
|
636
|
+
| 10. Scene tree pretty-printer | `scene.node_tree()` rendered as an indented tree with box-drawing characters |
|
|
637
|
+
|
|
638
|
+
**Interactive bone rotator** (section 8) requires ipywidgets:
|
|
639
|
+
|
|
640
|
+
```bash
|
|
641
|
+
pip install ipywidgets
|
|
642
|
+
```
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
### Connecting to DAZ Studio
|
|
647
|
+
|
|
648
|
+
```python
|
|
649
|
+
from dazpy import DazClient, DazScene
|
|
650
|
+
|
|
651
|
+
# Default: 127.0.0.1:18811, token auto-loaded from ~/.daz3d/dazscriptserver_token.txt
|
|
652
|
+
client = DazClient()
|
|
653
|
+
|
|
654
|
+
# Explicit connection
|
|
655
|
+
client = DazClient(host="127.0.0.1", port=18811, token="your-token", timeout=30.0)
|
|
656
|
+
|
|
657
|
+
scene = DazScene(client) # or just DazScene() to use a default client
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`DazClient` also exposes `status()`, `health()`, and `metrics()` for server introspection.
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
### Scene Graph
|
|
665
|
+
|
|
666
|
+
`DazScene` is the primary entry point. All methods execute DazScript on the server and
|
|
667
|
+
return typed Python objects.
|
|
668
|
+
|
|
669
|
+
```python
|
|
670
|
+
scene = DazScene()
|
|
671
|
+
|
|
672
|
+
# Node counts
|
|
673
|
+
print(scene.num_nodes()) # total nodes
|
|
674
|
+
print(scene.num_skeletons()) # figures only
|
|
675
|
+
|
|
676
|
+
# Get all nodes (returns DazSkeleton / DazCamera / DazLight / DazNode as appropriate)
|
|
677
|
+
for node in scene.nodes():
|
|
678
|
+
print(node.name(), node.label(), node.position())
|
|
679
|
+
|
|
680
|
+
# Find nodes
|
|
681
|
+
node = scene.find_node("Genesis9")
|
|
682
|
+
node = scene.find_node_by_label("Genesis 9")
|
|
683
|
+
|
|
684
|
+
# Scene tree (hierarchy as nested dicts)
|
|
685
|
+
tree = scene.node_tree()
|
|
686
|
+
|
|
687
|
+
# All transforms in one round-trip
|
|
688
|
+
transforms = scene.all_node_transforms()
|
|
689
|
+
|
|
690
|
+
# Selection
|
|
691
|
+
selected = scene.selected_nodes()
|
|
692
|
+
primary = scene.primary_selection()
|
|
693
|
+
scene.set_primary_selection(node)
|
|
694
|
+
scene.select_all(on=False)
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
#### DazNode
|
|
698
|
+
|
|
699
|
+
Every scene object is a `DazNode`. Common operations:
|
|
700
|
+
|
|
701
|
+
```python
|
|
702
|
+
node = scene.find_node_by_label("Cube")
|
|
703
|
+
|
|
704
|
+
# Transforms (world-space)
|
|
705
|
+
pos = node.position() # {"x": 0.0, "y": 0.0, "z": 0.0}
|
|
706
|
+
rot = node.rotation() # {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}
|
|
707
|
+
node.set_position(10, 0, 0)
|
|
708
|
+
node.set_rotation(0, 45, 0) # degrees
|
|
709
|
+
|
|
710
|
+
# Local-space transforms
|
|
711
|
+
lpos = node.local_position()
|
|
712
|
+
lrot = node.local_rotation()
|
|
713
|
+
node.set_local_position(0, 100, 0)
|
|
714
|
+
node.set_local_rotation(0, 0, 30)
|
|
715
|
+
|
|
716
|
+
# Scale
|
|
717
|
+
gs = node.general_scale() # uniform scale float
|
|
718
|
+
axes = node.scale() # {"x": 1.0, "y": 1.0, "z": 1.0, "general": 1.0}
|
|
719
|
+
|
|
720
|
+
# Visibility and scene membership
|
|
721
|
+
node.is_visible()
|
|
722
|
+
node.is_visible_in_render()
|
|
723
|
+
node.set_visible_in_render(False)
|
|
724
|
+
node.is_visible_in_viewport()
|
|
725
|
+
node.set_visible_in_viewport(True)
|
|
726
|
+
node.is_in_scene()
|
|
727
|
+
node.is_root()
|
|
728
|
+
|
|
729
|
+
# Bounding box
|
|
730
|
+
bb = node.bounding_box() # {"min": {"x":…, "y":…, "z":…}, "max": {…}}
|
|
731
|
+
|
|
732
|
+
# Selection
|
|
733
|
+
node.is_selected()
|
|
734
|
+
node.select(True)
|
|
735
|
+
|
|
736
|
+
# Generic property access (fallback for anything not wrapped)
|
|
737
|
+
val = node.get_property("Some Property Label")
|
|
738
|
+
node.set_property("Some Property Label", 1.0)
|
|
739
|
+
```
|
|
740
|
+
|
|
741
|
+
---
|
|
742
|
+
|
|
743
|
+
### Figures — Skeletons and Bones
|
|
744
|
+
|
|
745
|
+
Figures in DAZ Studio are `DzSkeleton` instances. Posing a figure means setting
|
|
746
|
+
bone rotations.
|
|
747
|
+
|
|
748
|
+
```python
|
|
749
|
+
# Find a figure
|
|
750
|
+
figure = scene.find_skeleton_by_label("Genesis 9")
|
|
751
|
+
# or: scene.find_skeleton("Genesis9"), scene.skeletons()
|
|
752
|
+
|
|
753
|
+
# Bones
|
|
754
|
+
all_bones = figure.bones() # list[DazBone]
|
|
755
|
+
forearm = figure.find_bone("r_forearm") # Genesis 9; use figure.bones() to list names
|
|
756
|
+
forearm = figure.find_bone_by_label("Right Forearm")
|
|
757
|
+
n = figure.num_bones()
|
|
758
|
+
|
|
759
|
+
# Follow target (clothing/hair fitted to a figure)
|
|
760
|
+
target = figure.follow_target() # DazSkeleton | None
|
|
761
|
+
|
|
762
|
+
# DazBone — pose by setting local Euler angles (degrees)
|
|
763
|
+
rot = forearm.local_rotation() # {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}
|
|
764
|
+
forearm.set_local_rotation(0, 0, 45) # bend forearm 45° on Z axis
|
|
765
|
+
|
|
766
|
+
pos = forearm.local_position()
|
|
767
|
+
order = forearm.rotation_order() # "XYZ", "ZYX", etc.
|
|
768
|
+
skel = forearm.get_skeleton() # back-reference to the figure
|
|
769
|
+
|
|
770
|
+
# Example: pose a figure's arm
|
|
771
|
+
with scene.undo("Pose arm"):
|
|
772
|
+
figure.find_bone("lShldrBend").set_local_rotation(0, 0, -60)
|
|
773
|
+
figure.find_bone("lForeArm").set_local_rotation(0, 0, -30)
|
|
774
|
+
```
|
|
775
|
+
|
|
776
|
+
`scene.nodes()` automatically returns `DazSkeleton` for figure nodes so you can
|
|
777
|
+
iterate and call `bones()` without an extra lookup.
|
|
778
|
+
|
|
779
|
+
---
|
|
780
|
+
|
|
781
|
+
### Morphs
|
|
782
|
+
|
|
783
|
+
Morphs control body shapes, facial expressions, and clothing fits.
|
|
784
|
+
|
|
785
|
+
```python
|
|
786
|
+
figure = scene.find_skeleton_by_label("Genesis 9")
|
|
787
|
+
|
|
788
|
+
# List all modifiers on a node
|
|
789
|
+
for mod in figure.modifiers():
|
|
790
|
+
print(mod.name())
|
|
791
|
+
|
|
792
|
+
# Morphs only (DzMorph subclass)
|
|
793
|
+
for morph in figure.morphs():
|
|
794
|
+
print(morph.name(), morph.value())
|
|
795
|
+
|
|
796
|
+
# Find and adjust a specific morph
|
|
797
|
+
smile = figure.find_modifier("PHMSmileOpen")
|
|
798
|
+
print(smile.value()) # 0.0–1.0
|
|
799
|
+
smile.set_value(0.8)
|
|
800
|
+
|
|
801
|
+
# Or via DazMorph properties
|
|
802
|
+
morph = figure.find_modifier("BodyMorphHeavy")
|
|
803
|
+
morph.value = 0.5 # property-style write
|
|
804
|
+
print(morph.min, morph.max)
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
---
|
|
808
|
+
|
|
809
|
+
### Materials
|
|
810
|
+
|
|
811
|
+
```python
|
|
812
|
+
node = scene.find_node_by_label("Genesis 9 Skin")
|
|
813
|
+
|
|
814
|
+
# All materials on a node
|
|
815
|
+
for mat in node.materials():
|
|
816
|
+
print(mat.name(), mat.diffuse_color())
|
|
817
|
+
|
|
818
|
+
# Find a specific material
|
|
819
|
+
skin = node.find_material("Torso")
|
|
820
|
+
|
|
821
|
+
# Color and opacity
|
|
822
|
+
color = skin.diffuse_color() # {"r": 255, "g": 220, "b": 200}
|
|
823
|
+
skin.set_diffuse_color(255, 210, 190)
|
|
824
|
+
print(skin.opacity()) # 1.0
|
|
825
|
+
print(skin.color_map()) # texture filename or None
|
|
826
|
+
print(skin.is_opaque())
|
|
827
|
+
print(skin.smoothing_angle())
|
|
828
|
+
print(skin.is_smoothing_on())
|
|
829
|
+
|
|
830
|
+
# Generic property fallback
|
|
831
|
+
skin.set_property("Glossy Reflectivity", 0.3)
|
|
832
|
+
```
|
|
833
|
+
|
|
834
|
+
---
|
|
835
|
+
|
|
836
|
+
### Cameras and Lights
|
|
837
|
+
|
|
838
|
+
```python
|
|
839
|
+
# Cameras
|
|
840
|
+
for cam in scene.cameras():
|
|
841
|
+
print(cam.name(), cam.focal_length(), cam.is_view_camera())
|
|
842
|
+
|
|
843
|
+
cam = scene.cameras()[0]
|
|
844
|
+
cam.focal_length() # mm
|
|
845
|
+
cam.frame_width()
|
|
846
|
+
cam.focal_distance()
|
|
847
|
+
cam.aspect_width()
|
|
848
|
+
cam.aspect_height()
|
|
849
|
+
cam.pixels_width()
|
|
850
|
+
cam.pixels_height()
|
|
851
|
+
cam.near_clipping_plane()
|
|
852
|
+
cam.far_clipping_plane()
|
|
853
|
+
cam.focal_point() # {"x": …, "y": …, "z": …}
|
|
854
|
+
cam.aim_at(0, 150, 0) # aim camera at world point
|
|
855
|
+
|
|
856
|
+
# Lights
|
|
857
|
+
for light in scene.lights():
|
|
858
|
+
print(light.name(), light.is_on(), light.is_directional())
|
|
859
|
+
|
|
860
|
+
light = scene.lights()[0]
|
|
861
|
+
light.is_on()
|
|
862
|
+
light.is_directional()
|
|
863
|
+
light.is_area_light()
|
|
864
|
+
light.direction() # {"x": …, "y": …, "z": …} (directional lights only)
|
|
865
|
+
light.set_color(1.0, 0.9, 0.8)
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
---
|
|
869
|
+
|
|
870
|
+
### Scene I/O and Timeline
|
|
871
|
+
|
|
872
|
+
```python
|
|
873
|
+
# Load and save
|
|
874
|
+
scene.load("/path/to/scene.duf") # merge mode (does not clear existing scene)
|
|
875
|
+
scene.save("/path/to/output.duf")
|
|
876
|
+
scene.save_copy("/path/to/backup.duf") # write copy; filename/dirty flag unchanged
|
|
877
|
+
print(scene.filename())
|
|
878
|
+
print(scene.needs_save())
|
|
879
|
+
|
|
880
|
+
# Timeline
|
|
881
|
+
print(scene.frame())
|
|
882
|
+
scene.set_frame(24)
|
|
883
|
+
print(scene.play_range()) # {"start": 0, "end": 240}
|
|
884
|
+
scene.set_play_range(0, 120)
|
|
885
|
+
scene.set_anim_range(0, 240)
|
|
886
|
+
print(scene.is_playing())
|
|
887
|
+
scene.loop_playback(True)
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
892
|
+
### Geometry
|
|
893
|
+
|
|
894
|
+
`DazGeometry` provides access to the raw mesh data of a node's shape.
|
|
895
|
+
|
|
896
|
+
```python
|
|
897
|
+
node = scene.find_node_by_label("My Prop")
|
|
898
|
+
geo = node.geometry()
|
|
899
|
+
|
|
900
|
+
print(geo.num_vertices())
|
|
901
|
+
print(geo.num_faces())
|
|
902
|
+
print(geo.subdivision_level())
|
|
903
|
+
print(geo.tris_count(), geo.quads_count())
|
|
904
|
+
|
|
905
|
+
# Vertices (chunked — returns slice starting at offset)
|
|
906
|
+
verts = geo.vertices(start=0, count=1000)
|
|
907
|
+
# {"total": 5000, "start": 0, "vertices": [[x,y,z], …]}
|
|
908
|
+
|
|
909
|
+
# Faces (vertex indices per polygon)
|
|
910
|
+
faces = geo.face_vertex_indices(start=0, count=1000)
|
|
911
|
+
# {"total": 4800, "start": 0, "facets": [[v0,v1,v2,v3], …]}
|
|
912
|
+
|
|
913
|
+
# Normals
|
|
914
|
+
normals = geo.normals(start=0, count=5000)
|
|
915
|
+
|
|
916
|
+
# UV sets
|
|
917
|
+
print(geo.uv_set_count())
|
|
918
|
+
uvs = geo.uv_positions(uv_set=0, start=0, count=5000)
|
|
919
|
+
|
|
920
|
+
# Groups
|
|
921
|
+
print(geo.face_group_names())
|
|
922
|
+
print(geo.material_group_names())
|
|
923
|
+
```
|
|
924
|
+
|
|
925
|
+
---
|
|
926
|
+
|
|
927
|
+
### Advanced: Batch, Undo, and Async
|
|
928
|
+
|
|
929
|
+
#### Undo Groups
|
|
930
|
+
|
|
931
|
+
Wrap multiple changes in a single undo step visible in DAZ Studio's Edit menu:
|
|
932
|
+
|
|
933
|
+
```python
|
|
934
|
+
with scene.undo("Apply pose"):
|
|
935
|
+
figure.find_bone("lShldrBend").set_local_rotation(0, 0, -60)
|
|
936
|
+
figure.find_bone("rShldrBend").set_local_rotation(0, 0, 60)
|
|
937
|
+
figure.find_modifier("PHMSmileOpen").set_value(0.5)
|
|
938
|
+
```
|
|
939
|
+
|
|
940
|
+
#### Batch Execution
|
|
941
|
+
|
|
942
|
+
`Batch` groups multiple DazScript snippets into a single HTTP round-trip:
|
|
943
|
+
|
|
944
|
+
```python
|
|
945
|
+
from dazpy import Batch
|
|
946
|
+
|
|
947
|
+
batch = Batch(client)
|
|
948
|
+
batch.add("(function(){ return Scene.getNumNodes(); })()")
|
|
949
|
+
batch.add("(function(){ return Scene.getNumSkeletons(); })()")
|
|
950
|
+
results = batch.execute() # one HTTP request, list of ExecutionResult
|
|
951
|
+
print(results[0].value, results[1].value)
|
|
952
|
+
|
|
953
|
+
# BatchFuture — fire and collect later
|
|
954
|
+
future = batch.submit_async() # returns BatchFuture
|
|
955
|
+
# ... do other work ...
|
|
956
|
+
results = future.collect()
|
|
957
|
+
```
|
|
958
|
+
|
|
959
|
+
#### Long-Running Operations
|
|
960
|
+
|
|
961
|
+
`execute_long` wraps the async submit/poll/collect cycle for operations like renders:
|
|
962
|
+
|
|
963
|
+
```python
|
|
964
|
+
from dazpy import execute_long
|
|
965
|
+
|
|
966
|
+
result = execute_long(
|
|
967
|
+
client,
|
|
968
|
+
script="App.getRenderMgr().doRender(); return 'done';",
|
|
969
|
+
poll_interval=2.0,
|
|
970
|
+
timeout=300.0,
|
|
971
|
+
)
|
|
972
|
+
print(result.value)
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
---
|
|
976
|
+
|
|
977
|
+
### Error Handling
|
|
978
|
+
|
|
979
|
+
All dazpy exceptions are in `dazpy.exceptions`:
|
|
980
|
+
|
|
981
|
+
```python
|
|
982
|
+
from dazpy import DazClient, DazScene
|
|
983
|
+
from dazpy.exceptions import (
|
|
984
|
+
ConnectionError, # DAZ Studio not reachable
|
|
985
|
+
AuthenticationError, # bad token or IP blocked
|
|
986
|
+
ScriptSyntaxError, # DazScript parse error
|
|
987
|
+
ScriptRuntimeError, # DazScript runtime exception
|
|
988
|
+
TimeoutError, # HTTP request timed out
|
|
989
|
+
NodeNotFoundError, # scene.find_node / find_skeleton raised
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
try:
|
|
993
|
+
scene.find_skeleton_by_label("NonExistent")
|
|
994
|
+
except NodeNotFoundError as e:
|
|
995
|
+
print(e)
|
|
996
|
+
|
|
997
|
+
try:
|
|
998
|
+
client.execute("this is not valid { script")
|
|
999
|
+
except ScriptSyntaxError as e:
|
|
1000
|
+
print(e.request_id) # correlate with server log
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
`ScriptRuntimeError` and `ScriptSyntaxError` both carry a `request_id` attribute
|
|
1004
|
+
that matches the 8-character ID in the server's request log.
|
|
1005
|
+
|
|
1006
|
+
---
|
|
1007
|
+
|
|
1008
|
+
### Testing dazpy
|
|
1009
|
+
|
|
1010
|
+
The SDK ships with two test suites:
|
|
1011
|
+
|
|
1012
|
+
```bash
|
|
1013
|
+
# Unit tests — no DAZ Studio needed (mock-based)
|
|
1014
|
+
python tests_dazpy.py
|
|
1015
|
+
|
|
1016
|
+
# Integration tests — skip gracefully if server is down
|
|
1017
|
+
python tests_dazpy_integration.py
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
Tests requiring a live DAZ Studio session are gated with `@skip_no_daz` and
|
|
1021
|
+
silently skipped when the `Scene` global is unavailable.
|
|
1022
|
+
|
|
1023
|
+
---
|
|
1024
|
+
|
|
1025
|
+
## Getting the Plugin
|
|
1026
|
+
|
|
1027
|
+
There are two options for getting the plugin. For most users, Option A, downloading a pre-built release, is the easiest route.
|
|
1028
|
+
|
|
1029
|
+
### A. Download a pre-built release
|
|
1030
|
+
|
|
1031
|
+
1. Browse to the latest stable release page: https://github.com/bluemoonfoundry/daz-script-server/releases/latest
|
|
1032
|
+
2. Scroll down to the Assets section. Each release includes:
|
|
1033
|
+
- `DazScriptServer.dll` / `DazScriptServer.dylib` — the plugin
|
|
1034
|
+
- `dazpy-*.whl` — the Python SDK wheel (`pip install dazpy-*.whl`)
|
|
1035
|
+
3. Download `DazScriptServer.dll` and copy it to the plugins folder in your DAZ Studio installation:
|
|
1036
|
+
- **Windows:** `C:\Program Files\DAZ 3D\DAZStudio4\plugins\`
|
|
1037
|
+
- **macOS:** `/Applications/DAZ 3D/DAZStudio4/plugins/`
|
|
1038
|
+
- Unsure where DAZ Studio is installed? Right-click its icon → Properties → Open File Location.
|
|
1039
|
+
|
|
1040
|
+
### B. Building it yourself from source
|
|
1041
|
+
|
|
1042
|
+
1. **Download the DAZ Studio SDK** from the [DAZ Developer portal](https://www.daz3d.com/daz-studio-4-5-sdk)
|
|
1043
|
+
|
|
1044
|
+
2. **Configure with CMake:**
|
|
1045
|
+
```bash
|
|
1046
|
+
cmake -B build -S . -DDAZ_SDK_DIR="C:/path/to/DAZStudio4.5+ SDK"
|
|
1047
|
+
```
|
|
1048
|
+
|
|
1049
|
+
3. **Build:**
|
|
1050
|
+
```bash
|
|
1051
|
+
cmake --build build --config Release
|
|
1052
|
+
```
|
|
1053
|
+
|
|
1054
|
+
Output: `build/plugin/Release/DazScriptServer.dll` (Windows) or `build/plugin/DazScriptServer.dylib` (macOS)
|
|
1055
|
+
|
|
1056
|
+
### build.sh convenience script
|
|
1057
|
+
|
|
1058
|
+
`build.sh` auto-detects CMake and loads environment variables from `.env`.
|
|
1059
|
+
|
|
1060
|
+
**Commands** (first positional argument; defaults to `build`):
|
|
1061
|
+
|
|
1062
|
+
| Command | Description |
|
|
1063
|
+
|---|---|
|
|
1064
|
+
| `build` | Configure (if needed) and build (default) |
|
|
1065
|
+
| `install` | Build and copy plugin to DAZ Studio plugins folder |
|
|
1066
|
+
| `clean` | Delete the build directory and exit |
|
|
1067
|
+
| `release <tag>` | Build plugin + dazpy wheel, create a GitHub release and attach both |
|
|
1068
|
+
|
|
1069
|
+
**Options** (flags, combinable with any command):
|
|
1070
|
+
|
|
1071
|
+
| Option | Description |
|
|
1072
|
+
|---|---|
|
|
1073
|
+
| `--clean` | Wipe the build directory before building |
|
|
1074
|
+
| `--reconfigure` | Force CMake configure even if a cache already exists |
|
|
1075
|
+
| `--debug` | Build Debug config instead of Release |
|
|
1076
|
+
| `--verbose` | Pass `--verbose` to the CMake build step |
|
|
1077
|
+
| `--title <title>` | Release title (`release` only; defaults to tag) |
|
|
1078
|
+
| `--notes <text>` | Release notes text (`release` only) |
|
|
1079
|
+
| `--update` | Update an existing release instead of creating a new one (`release` only) |
|
|
1080
|
+
| `--no-wheel` | Skip building the dazpy wheel (`release` only) |
|
|
1081
|
+
| `--help` | Show usage and exit |
|
|
1082
|
+
|
|
1083
|
+
```bash
|
|
1084
|
+
./build.sh # build
|
|
1085
|
+
./build.sh build --clean --debug
|
|
1086
|
+
./build.sh install --clean # DAZ Studio must not be running
|
|
1087
|
+
./build.sh clean
|
|
1088
|
+
./build.sh release v1.3.0 --title "v1.3.0" --notes "Bug fixes"
|
|
1089
|
+
./build.sh release v1.3.0 --update # replace assets on existing release
|
|
1090
|
+
./build.sh release v1.3.0 --no-wheel # plugin DLL only, skip wheel
|
|
1091
|
+
```
|
|
1092
|
+
|
|
1093
|
+
### Installation
|
|
1094
|
+
|
|
1095
|
+
Copy the built plugin to DAZ Studio's plugins folder:
|
|
1096
|
+
|
|
1097
|
+
- **Windows:** `C:\Program Files\DAZ 3D\DAZStudio4\plugins\`
|
|
1098
|
+
- **macOS:** `/Applications/DAZ 3D/DAZStudio4/plugins/`
|
|
1099
|
+
|
|
1100
|
+
**Or build and install in one step** (set `DAZ_STUDIO_EXE_DIR` in `.env` first):
|
|
1101
|
+
```bash
|
|
1102
|
+
./build.sh install
|
|
1103
|
+
|
|
1104
|
+
# Clean build + install
|
|
1105
|
+
./build.sh install --clean
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
> **Note:** `install` requires DAZ Studio to be closed. The script detects if it is running and exits with a clear error rather than failing at link time.
|
|
1109
|
+
|
|
1110
|
+
---
|
|
1111
|
+
|
|
1112
|
+
## Starting the Server
|
|
1113
|
+
|
|
1114
|
+
1. Open DAZ Studio
|
|
1115
|
+
2. Go to **Window → Panes → Daz Script Server**
|
|
1116
|
+
3. Configure settings (see [Configuration](#-configuration) below)
|
|
1117
|
+
4. Click **Start Server**
|
|
1118
|
+
|
|
1119
|
+
The server status will show "Running" with active requests counter when started successfully.
|
|
1120
|
+
|
|
1121
|
+
---
|
|
1122
|
+
|
|
1123
|
+
## ⚙️ Configuration
|
|
1124
|
+
|
|
1125
|
+
All settings are persisted via QSettings and restored between DAZ Studio sessions.
|
|
1126
|
+
|
|
1127
|
+
### Server Settings
|
|
1128
|
+
|
|
1129
|
+
| Setting | Default | Range | Description |
|
|
1130
|
+
|---------|---------|-------|-------------|
|
|
1131
|
+
| **Host** | `127.0.0.1` | - | IP address to bind to (`127.0.0.1` for localhost only, `0.0.0.0` for all interfaces) |
|
|
1132
|
+
| **Port** | `18811` | 1024-65535 | Port number |
|
|
1133
|
+
| **Timeout** | `30` seconds | 5-300 | Script execution timeout |
|
|
1134
|
+
| **Auto-Start** | Disabled | - | Start server automatically when pane opens |
|
|
1135
|
+
|
|
1136
|
+
### 🔐 Authentication
|
|
1137
|
+
|
|
1138
|
+
| Setting | Default | Description |
|
|
1139
|
+
|---------|---------|-------------|
|
|
1140
|
+
| **Enable Authentication** | ✅ Enabled | Token-based authentication using cryptographically secure tokens (128-bit) |
|
|
1141
|
+
|
|
1142
|
+
**Token Security:**
|
|
1143
|
+
- Auto-generated using OS crypto APIs (Windows: CryptoAPI, macOS/Linux: `/dev/urandom`)
|
|
1144
|
+
- Stored in `~/.daz3d/dazscriptserver_token.txt`
|
|
1145
|
+
- File permissions automatically set to `chmod 600` (owner-only) on Unix/macOS
|
|
1146
|
+
- Copy token from UI using the **Copy** button
|
|
1147
|
+
- Regenerate with **Regenerate** button if compromised
|
|
1148
|
+
- ⚠️ **Disable at your own risk** - only on trusted networks
|
|
1149
|
+
|
|
1150
|
+
### 🛡️ IP Whitelist
|
|
1151
|
+
|
|
1152
|
+
| Setting | Default | Description |
|
|
1153
|
+
|---------|---------|-------------|
|
|
1154
|
+
| **Enable IP Whitelist** | ❌ Disabled | Restrict access to specific IP addresses |
|
|
1155
|
+
| **Allowed IPs** | `127.0.0.1` | Comma-separated list (e.g., `127.0.0.1, 192.168.1.100`) |
|
|
1156
|
+
|
|
1157
|
+
- Exact match only (wildcards not supported in v1.2.0)
|
|
1158
|
+
- Blocked IPs receive HTTP 403 Forbidden
|
|
1159
|
+
- Checked before authentication (efficient)
|
|
1160
|
+
- Essential for network-exposed deployments
|
|
1161
|
+
|
|
1162
|
+
### ⏱️ Rate Limiting
|
|
1163
|
+
|
|
1164
|
+
| Setting | Default | Range | Description |
|
|
1165
|
+
|---------|---------|-------|-------------|
|
|
1166
|
+
| **Enable Rate Limiting** | ❌ Disabled | - | Per-IP rate limiting to prevent abuse |
|
|
1167
|
+
| **Max Requests** | `60` | 10-1000 | Maximum requests per time window |
|
|
1168
|
+
| **Time Window** | `60` seconds | 10-300 | Time window in seconds |
|
|
1169
|
+
|
|
1170
|
+
- Uses sliding window algorithm for accuracy
|
|
1171
|
+
- Separate tracking per IP address
|
|
1172
|
+
- Exceeded IPs receive HTTP 429 Too Many Requests
|
|
1173
|
+
- Logs violations with timestamp and client IP
|
|
1174
|
+
|
|
1175
|
+
### 🎛️ Advanced Limits
|
|
1176
|
+
|
|
1177
|
+
| Setting | Default | Range | Description |
|
|
1178
|
+
|---------|---------|-------|-------------|
|
|
1179
|
+
| **Max Concurrent Requests** | `10` | 5-50 | Maximum simultaneous requests |
|
|
1180
|
+
| **Max Request Body Size** | `5` MB | 1-50 | Maximum request body size |
|
|
1181
|
+
| **Max Script Text Length** | `1024` KB (1 MB) | 100-10240 | Maximum inline script size |
|
|
1182
|
+
|
|
1183
|
+
**Protection:**
|
|
1184
|
+
- Prevents resource exhaustion
|
|
1185
|
+
- Returns appropriate HTTP errors (413, 429)
|
|
1186
|
+
- Use `scriptFile` for larger scripts
|
|
1187
|
+
|
|
1188
|
+
### 📊 Monitoring
|
|
1189
|
+
|
|
1190
|
+
- **Active Request Counter** - Real-time display: "Active Requests: 2 / 10"
|
|
1191
|
+
- **Request Log** - Detailed log with:
|
|
1192
|
+
- Timestamps (HH:mm:ss)
|
|
1193
|
+
- Client IP addresses
|
|
1194
|
+
- Status codes (OK, ERR, WARN, AUTH FAILED, BLOCKED, RATE LIMIT)
|
|
1195
|
+
- Duration (milliseconds)
|
|
1196
|
+
- Request IDs (8-character UUID)
|
|
1197
|
+
- Script identifiers
|
|
1198
|
+
- **Log Management** - Maximum 1000 lines, auto-remove old entries, "Clear Log" button
|
|
1199
|
+
|
|
1200
|
+
**Important:** Configuration changes (except auto-start) require stopping and restarting the server to take effect.
|
|
1201
|
+
|
|
1202
|
+
---
|
|
1203
|
+
|
|
1204
|
+
## 📡 API Reference
|
|
1205
|
+
|
|
1206
|
+
### Base URL
|
|
1207
|
+
```
|
|
1208
|
+
http://127.0.0.1:18811
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
### Authentication
|
|
1212
|
+
|
|
1213
|
+
All endpoints except `/status`, `/health`, and `/metrics` require authentication when enabled:
|
|
1214
|
+
|
|
1215
|
+
**Header Options:**
|
|
1216
|
+
- `X-API-Token: YOUR_TOKEN_HERE`
|
|
1217
|
+
- `Authorization: Bearer YOUR_TOKEN_HERE`
|
|
1218
|
+
|
|
1219
|
+
### HTTP Status Codes
|
|
1220
|
+
|
|
1221
|
+
| Code | Meaning |
|
|
1222
|
+
|------|---------|
|
|
1223
|
+
| `200` | Success (check `success` field in response) |
|
|
1224
|
+
| `400` | Bad Request (malformed JSON, invalid parameters) |
|
|
1225
|
+
| `401` | Unauthorized (missing or invalid token) |
|
|
1226
|
+
| `403` | Forbidden (IP not whitelisted) |
|
|
1227
|
+
| `413` | Payload Too Large (request body exceeds limit) |
|
|
1228
|
+
| `429` | Too Many Requests (concurrent or rate limit exceeded) |
|
|
1229
|
+
|
|
1230
|
+
---
|
|
1231
|
+
|
|
1232
|
+
### `GET /status`
|
|
1233
|
+
|
|
1234
|
+
**Purpose:** Check if server is running
|
|
1235
|
+
**Authentication:** Not required
|
|
1236
|
+
|
|
1237
|
+
**Response:**
|
|
1238
|
+
```json
|
|
1239
|
+
{
|
|
1240
|
+
"running": true,
|
|
1241
|
+
"version": "2.0.0"
|
|
1242
|
+
}
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
---
|
|
1246
|
+
|
|
1247
|
+
### `GET /health`
|
|
1248
|
+
|
|
1249
|
+
**Purpose:** Health check for monitoring and load balancers
|
|
1250
|
+
**Authentication:** Not required
|
|
1251
|
+
|
|
1252
|
+
**Response:**
|
|
1253
|
+
```json
|
|
1254
|
+
{
|
|
1255
|
+
"status": "ok",
|
|
1256
|
+
"version": "2.0.0",
|
|
1257
|
+
"running": true,
|
|
1258
|
+
"auth_enabled": true,
|
|
1259
|
+
"active_requests": 2,
|
|
1260
|
+
"uptime_seconds": 3600
|
|
1261
|
+
}
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
---
|
|
1265
|
+
|
|
1266
|
+
### `GET /metrics`
|
|
1267
|
+
|
|
1268
|
+
**Purpose:** Request statistics and performance tracking
|
|
1269
|
+
**Authentication:** Not required
|
|
1270
|
+
|
|
1271
|
+
**Response:**
|
|
1272
|
+
```json
|
|
1273
|
+
{
|
|
1274
|
+
"total_requests": 1523,
|
|
1275
|
+
"successful_requests": 1489,
|
|
1276
|
+
"failed_requests": 28,
|
|
1277
|
+
"auth_failures": 6,
|
|
1278
|
+
"active_requests": 2,
|
|
1279
|
+
"uptime_seconds": 86400,
|
|
1280
|
+
"success_rate_percent": 97.77
|
|
1281
|
+
}
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
**Note:** Counters persist across DAZ Studio restarts (saved to QSettings).
|
|
1285
|
+
|
|
1286
|
+
---
|
|
1287
|
+
|
|
1288
|
+
### `POST /execute`
|
|
1289
|
+
|
|
1290
|
+
**Purpose:** Execute a DazScript and return the result
|
|
1291
|
+
**Authentication:** Required (if enabled)
|
|
1292
|
+
|
|
1293
|
+
**Request Body:**
|
|
1294
|
+
|
|
1295
|
+
**Option 1: Inline script**
|
|
1296
|
+
```json
|
|
1297
|
+
{
|
|
1298
|
+
"script": "(function(){ return Scene.getNumNodes(); })()",
|
|
1299
|
+
"args": { "key": "value" }
|
|
1300
|
+
}
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
**Option 2: Script file**
|
|
1304
|
+
```json
|
|
1305
|
+
{
|
|
1306
|
+
"scriptFile": "/absolute/path/to/script.dsa",
|
|
1307
|
+
"args": { "key": "value" }
|
|
1308
|
+
}
|
|
1309
|
+
```
|
|
1310
|
+
|
|
1311
|
+
**Parameters:**
|
|
1312
|
+
|
|
1313
|
+
| Field | Type | Required | Description |
|
|
1314
|
+
|-------|------|----------|-------------|
|
|
1315
|
+
| `script` | string | one of | Inline DazScript code (max configurable, default 1MB) |
|
|
1316
|
+
| `scriptFile` | string | one of | Absolute path to `.dsa` file |
|
|
1317
|
+
| `args` | object | optional | Arguments accessible via `getArguments()[0]` |
|
|
1318
|
+
|
|
1319
|
+
**Note:** If both `script` and `scriptFile` are provided, `scriptFile` takes precedence.
|
|
1320
|
+
|
|
1321
|
+
**Response:**
|
|
1322
|
+
```json
|
|
1323
|
+
{
|
|
1324
|
+
"success": true,
|
|
1325
|
+
"result": 42,
|
|
1326
|
+
"output": ["line 1", "line 2"],
|
|
1327
|
+
"error": null,
|
|
1328
|
+
"request_id": "a3f2b891"
|
|
1329
|
+
}
|
|
1330
|
+
```
|
|
1331
|
+
|
|
1332
|
+
**Response Fields:**
|
|
1333
|
+
|
|
1334
|
+
| Field | Description |
|
|
1335
|
+
|-------|-------------|
|
|
1336
|
+
| `success` | `true` if script executed without error |
|
|
1337
|
+
| `result` | Script's return value, `null` on error |
|
|
1338
|
+
| `output` | Lines printed via `print()` (max 10,000) |
|
|
1339
|
+
| `error` | Error message with line number, or `null` |
|
|
1340
|
+
| `request_id` | Unique 8-character ID for log correlation |
|
|
1341
|
+
|
|
1342
|
+
**Processing Order:**
|
|
1343
|
+
1. Concurrent limit check
|
|
1344
|
+
2. IP whitelist check (if enabled)
|
|
1345
|
+
3. Rate limit check (if enabled)
|
|
1346
|
+
4. Body size validation
|
|
1347
|
+
5. Authentication (if enabled)
|
|
1348
|
+
6. Input validation
|
|
1349
|
+
7. Script execution
|
|
1350
|
+
|
|
1351
|
+
---
|
|
1352
|
+
|
|
1353
|
+
## 📦 Script Registry
|
|
1354
|
+
|
|
1355
|
+
The script registry allows you to register scripts once and execute them by name/ID on subsequent requests, avoiding retransmission of large script bodies.
|
|
1356
|
+
|
|
1357
|
+
**Key Features:**
|
|
1358
|
+
- Session-only storage (cleared on DAZ Studio restart)
|
|
1359
|
+
- Clients should re-register on HTTP 404
|
|
1360
|
+
- Register, list, execute by ID, and delete operations
|
|
1361
|
+
- Same response format as `/execute`
|
|
1362
|
+
|
|
1363
|
+
---
|
|
1364
|
+
|
|
1365
|
+
### `POST /scripts/register`
|
|
1366
|
+
|
|
1367
|
+
**Purpose:** Register or update a named script
|
|
1368
|
+
**Authentication:** Required (if enabled)
|
|
1369
|
+
|
|
1370
|
+
**Request:**
|
|
1371
|
+
```json
|
|
1372
|
+
{
|
|
1373
|
+
"name": "scene-info",
|
|
1374
|
+
"description": "Return scene node count",
|
|
1375
|
+
"script": "(function(){ return { nodes: Scene.getNumNodes() }; })()"
|
|
1376
|
+
}
|
|
1377
|
+
```
|
|
1378
|
+
|
|
1379
|
+
**Parameters:**
|
|
1380
|
+
|
|
1381
|
+
| Field | Type | Required | Description |
|
|
1382
|
+
|-------|------|----------|-------------|
|
|
1383
|
+
| `name` | string | yes | Script ID: 1-64 chars, `[A-Za-z0-9_-]` only |
|
|
1384
|
+
| `script` | string | yes | DazScript source code |
|
|
1385
|
+
| `description` | string | no | Human-readable description |
|
|
1386
|
+
|
|
1387
|
+
**Response:**
|
|
1388
|
+
```json
|
|
1389
|
+
{
|
|
1390
|
+
"success": true,
|
|
1391
|
+
"id": "scene-info",
|
|
1392
|
+
"registered_at": "2026-03-27T10:15:00",
|
|
1393
|
+
"updated": false
|
|
1394
|
+
}
|
|
1395
|
+
```
|
|
1396
|
+
|
|
1397
|
+
**Note:** Re-registering an existing name overwrites the script and sets `updated: true`.
|
|
1398
|
+
|
|
1399
|
+
---
|
|
1400
|
+
|
|
1401
|
+
### `GET /scripts`
|
|
1402
|
+
|
|
1403
|
+
**Purpose:** List all registered scripts
|
|
1404
|
+
**Authentication:** Required (if enabled)
|
|
1405
|
+
|
|
1406
|
+
**Response:**
|
|
1407
|
+
```json
|
|
1408
|
+
{
|
|
1409
|
+
"scripts": [
|
|
1410
|
+
{
|
|
1411
|
+
"id": "scene-info",
|
|
1412
|
+
"description": "Return scene node count",
|
|
1413
|
+
"registered_at": "2026-03-27T10:15:00"
|
|
1414
|
+
}
|
|
1415
|
+
],
|
|
1416
|
+
"count": 1
|
|
1417
|
+
}
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
---
|
|
1421
|
+
|
|
1422
|
+
### `POST /scripts/:id/execute`
|
|
1423
|
+
|
|
1424
|
+
**Purpose:** Execute a registered script by ID
|
|
1425
|
+
**Authentication:** Required (if enabled)
|
|
1426
|
+
|
|
1427
|
+
**Request:**
|
|
1428
|
+
```json
|
|
1429
|
+
{
|
|
1430
|
+
"args": { "label": "FN Ethan" }
|
|
1431
|
+
}
|
|
1432
|
+
```
|
|
1433
|
+
|
|
1434
|
+
**Response:** Same format as `POST /execute`
|
|
1435
|
+
|
|
1436
|
+
**Error Response (404):**
|
|
1437
|
+
```json
|
|
1438
|
+
{
|
|
1439
|
+
"success": false,
|
|
1440
|
+
"error": "Script not found: 'scene-info'"
|
|
1441
|
+
}
|
|
1442
|
+
```
|
|
1443
|
+
|
|
1444
|
+
**Handling 404:** Client should detect 404, re-register scripts, then retry.
|
|
1445
|
+
|
|
1446
|
+
---
|
|
1447
|
+
|
|
1448
|
+
### `DELETE /scripts/:id`
|
|
1449
|
+
|
|
1450
|
+
**Purpose:** Remove a script from the registry
|
|
1451
|
+
**Authentication:** Required (if enabled)
|
|
1452
|
+
|
|
1453
|
+
**Response:**
|
|
1454
|
+
```json
|
|
1455
|
+
{
|
|
1456
|
+
"success": true,
|
|
1457
|
+
"id": "scene-info"
|
|
1458
|
+
}
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
**Error Response (404):**
|
|
1462
|
+
```json
|
|
1463
|
+
{
|
|
1464
|
+
"success": false,
|
|
1465
|
+
"error": "Script not found: 'scene-info'"
|
|
1466
|
+
}
|
|
1467
|
+
```
|
|
1468
|
+
|
|
1469
|
+
---
|
|
1470
|
+
|
|
1471
|
+
## ⚡ Async Operations
|
|
1472
|
+
|
|
1473
|
+
For long-running scripts (renders, exports, batch jobs), use the async endpoints to avoid blocking the HTTP connection. Scripts still execute serially on DAZ Studio's main thread — async means the HTTP response is returned immediately while execution is queued.
|
|
1474
|
+
|
|
1475
|
+
**Typical async workflow:**
|
|
1476
|
+
|
|
1477
|
+
```python
|
|
1478
|
+
import requests, time
|
|
1479
|
+
|
|
1480
|
+
BASE = "http://127.0.0.1:18811"
|
|
1481
|
+
HEADERS = {"X-API-Token": token}
|
|
1482
|
+
|
|
1483
|
+
# 1. Submit asynchronously
|
|
1484
|
+
r = requests.post(f"{BASE}/execute/async", headers=HEADERS,
|
|
1485
|
+
json={"script": "App.getRenderMgr().doRender(); return 'done';"})
|
|
1486
|
+
req_id = r.json()["request_id"]
|
|
1487
|
+
|
|
1488
|
+
# 2. Poll until complete
|
|
1489
|
+
while True:
|
|
1490
|
+
status = requests.get(f"{BASE}/requests/{req_id}/status", headers=HEADERS).json()
|
|
1491
|
+
if status["status"] in ("completed", "failed", "cancelled"):
|
|
1492
|
+
break
|
|
1493
|
+
time.sleep(2)
|
|
1494
|
+
|
|
1495
|
+
# 3. Fetch result
|
|
1496
|
+
result = requests.get(f"{BASE}/requests/{req_id}/result", headers=HEADERS).json()
|
|
1497
|
+
print(result["result"])
|
|
1498
|
+
```
|
|
1499
|
+
|
|
1500
|
+
**Or use `?wait=true` to long-poll in one step:**
|
|
1501
|
+
|
|
1502
|
+
```python
|
|
1503
|
+
result = requests.get(f"{BASE}/requests/{req_id}/result?wait=true&timeout=300",
|
|
1504
|
+
headers=HEADERS).json()
|
|
1505
|
+
```
|
|
1506
|
+
|
|
1507
|
+
---
|
|
1508
|
+
|
|
1509
|
+
### `POST /execute/async`
|
|
1510
|
+
|
|
1511
|
+
**Purpose:** Submit an inline script for asynchronous execution
|
|
1512
|
+
**Authentication:** Required (if enabled)
|
|
1513
|
+
|
|
1514
|
+
**Request Body:** Same as `POST /execute`
|
|
1515
|
+
|
|
1516
|
+
**Response (immediate):**
|
|
1517
|
+
```json
|
|
1518
|
+
{
|
|
1519
|
+
"request_id": "a3f2b891",
|
|
1520
|
+
"status": "queued",
|
|
1521
|
+
"submitted_at": "2026-04-09T10:15:00"
|
|
1522
|
+
}
|
|
1523
|
+
```
|
|
1524
|
+
|
|
1525
|
+
---
|
|
1526
|
+
|
|
1527
|
+
### `POST /scripts/:id/async`
|
|
1528
|
+
|
|
1529
|
+
**Purpose:** Submit a registered script for asynchronous execution
|
|
1530
|
+
**Authentication:** Required (if enabled)
|
|
1531
|
+
|
|
1532
|
+
**Request Body:** Same as `POST /scripts/:id/execute`
|
|
1533
|
+
|
|
1534
|
+
**Response (immediate):** Same shape as `POST /execute/async`
|
|
1535
|
+
|
|
1536
|
+
---
|
|
1537
|
+
|
|
1538
|
+
### `GET /requests/:id/status`
|
|
1539
|
+
|
|
1540
|
+
**Purpose:** Poll the status of an async request
|
|
1541
|
+
**Authentication:** Required (if enabled)
|
|
1542
|
+
|
|
1543
|
+
**Response:**
|
|
1544
|
+
```json
|
|
1545
|
+
{
|
|
1546
|
+
"request_id": "a3f2b891",
|
|
1547
|
+
"status": "running",
|
|
1548
|
+
"progress": 0.0,
|
|
1549
|
+
"elapsed_ms": 1240,
|
|
1550
|
+
"queue_position": 0
|
|
1551
|
+
}
|
|
1552
|
+
```
|
|
1553
|
+
|
|
1554
|
+
**Status values:** `queued`, `running`, `completed`, `failed`, `cancelled`
|
|
1555
|
+
|
|
1556
|
+
`queue_position` is only meaningful when `status` is `queued`.
|
|
1557
|
+
|
|
1558
|
+
---
|
|
1559
|
+
|
|
1560
|
+
### `GET /requests/:id/result`
|
|
1561
|
+
|
|
1562
|
+
**Purpose:** Fetch the final result of an async request
|
|
1563
|
+
**Authentication:** Required (if enabled)
|
|
1564
|
+
|
|
1565
|
+
**Query Parameters:**
|
|
1566
|
+
|
|
1567
|
+
| Parameter | Default | Description |
|
|
1568
|
+
|-----------|---------|-------------|
|
|
1569
|
+
| `wait` | `false` | Block until the request completes |
|
|
1570
|
+
| `timeout` | `300` | Max seconds to wait when `wait=true` |
|
|
1571
|
+
|
|
1572
|
+
**Response (completed):**
|
|
1573
|
+
```json
|
|
1574
|
+
{
|
|
1575
|
+
"success": true,
|
|
1576
|
+
"result": "done",
|
|
1577
|
+
"output": [],
|
|
1578
|
+
"error": null,
|
|
1579
|
+
"request_id": "a3f2b891",
|
|
1580
|
+
"duration_ms": 45230,
|
|
1581
|
+
"completed_at": "2026-04-09T10:15:47",
|
|
1582
|
+
"status": "completed"
|
|
1583
|
+
}
|
|
1584
|
+
```
|
|
1585
|
+
|
|
1586
|
+
Returns HTTP 404 if the request ID is unknown or has been purged (TTL: 1 hour).
|
|
1587
|
+
|
|
1588
|
+
---
|
|
1589
|
+
|
|
1590
|
+
### `DELETE /requests/:id`
|
|
1591
|
+
|
|
1592
|
+
**Purpose:** Cancel a queued or running async request
|
|
1593
|
+
**Authentication:** Required (if enabled)
|
|
1594
|
+
|
|
1595
|
+
**Cancellation behaviour:**
|
|
1596
|
+
- `queued` → removed from queue immediately
|
|
1597
|
+
- `running` → cancel flag set + `killRender()` called; DAZ Studio honours the flag when the script returns
|
|
1598
|
+
|
|
1599
|
+
**Response:**
|
|
1600
|
+
```json
|
|
1601
|
+
{
|
|
1602
|
+
"request_id": "a3f2b891",
|
|
1603
|
+
"status": "cancelled",
|
|
1604
|
+
"cancelled_at": "2026-04-09T10:15:05"
|
|
1605
|
+
}
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
---
|
|
1609
|
+
|
|
1610
|
+
### `GET /requests`
|
|
1611
|
+
|
|
1612
|
+
**Purpose:** List all active and recently completed async requests
|
|
1613
|
+
**Authentication:** Required (if enabled)
|
|
1614
|
+
|
|
1615
|
+
**Response:**
|
|
1616
|
+
```json
|
|
1617
|
+
{
|
|
1618
|
+
"requests": [
|
|
1619
|
+
{
|
|
1620
|
+
"request_id": "a3f2b891",
|
|
1621
|
+
"status": "running",
|
|
1622
|
+
"submitted_at": "2026-04-09T10:15:00",
|
|
1623
|
+
"elapsed_ms": 1240
|
|
1624
|
+
}
|
|
1625
|
+
],
|
|
1626
|
+
"total": 1,
|
|
1627
|
+
"queued": 0,
|
|
1628
|
+
"running": 1,
|
|
1629
|
+
"completed": 0
|
|
1630
|
+
}
|
|
1631
|
+
```
|
|
1632
|
+
|
|
1633
|
+
**Note:** Completed, failed, and cancelled requests are automatically purged after 1 hour.
|
|
1634
|
+
|
|
1635
|
+
---
|
|
1636
|
+
|
|
1637
|
+
## 🔔 Scene Events (SSE)
|
|
1638
|
+
|
|
1639
|
+
`GET /scene/events` opens a persistent HTTP connection and streams DAZ Studio scene-change notifications as **Server-Sent Events**. Each event is a UTF-8 line in the form:
|
|
1640
|
+
|
|
1641
|
+
```
|
|
1642
|
+
data: {"type":"node.added","ts":1716307200123,"data":{"node_id":"0x1a2b3c4d","node_name":"Genesis 9","node_type":"DzFigure"}}
|
|
1643
|
+
|
|
1644
|
+
```
|
|
1645
|
+
|
|
1646
|
+
A `:keepalive` comment is sent every 3 seconds of idle time so clients can detect disconnects without sending an event:
|
|
1647
|
+
|
|
1648
|
+
```
|
|
1649
|
+
:keepalive
|
|
1650
|
+
|
|
1651
|
+
```
|
|
1652
|
+
|
|
1653
|
+
The connection stays open until the client disconnects or the server is stopped. All configured security checks (auth, IP whitelist) apply.
|
|
1654
|
+
|
|
1655
|
+
---
|
|
1656
|
+
|
|
1657
|
+
### `GET /scene/events`
|
|
1658
|
+
|
|
1659
|
+
**Purpose:** Subscribe to real-time scene-change notifications
|
|
1660
|
+
**Authentication:** Required (if enabled)
|
|
1661
|
+
|
|
1662
|
+
**Query Parameters:**
|
|
1663
|
+
|
|
1664
|
+
| Parameter | Default | Description |
|
|
1665
|
+
|-----------|---------|-------------|
|
|
1666
|
+
| `filter` | _(all)_ | Comma-separated list of event categories to receive. Omit to receive all. |
|
|
1667
|
+
|
|
1668
|
+
**Filter categories:**
|
|
1669
|
+
|
|
1670
|
+
| Value | Events included |
|
|
1671
|
+
|-------|----------------|
|
|
1672
|
+
| `node` | `node.added`, `node.removed` |
|
|
1673
|
+
| `skeleton` | `skeleton.added`, `skeleton.removed` |
|
|
1674
|
+
| `light` | `light.added`, `light.removed` |
|
|
1675
|
+
| `camera` | `camera.added`, `camera.removed` |
|
|
1676
|
+
| `selection` | `selection.list_changed`, `selection.primary_changed` |
|
|
1677
|
+
| `scene` | `scene.loading`, `scene.loaded`, `scene.saving`, `scene.saved`, `scene.clear_starting`, `scene.cleared` |
|
|
1678
|
+
| `time` | `time.changed` _(debounced 150 ms)_, `playback.started`, `playback.finished` |
|
|
1679
|
+
| `render` | `render.started`, `render.finished` |
|
|
1680
|
+
|
|
1681
|
+
**Response headers:**
|
|
1682
|
+
```
|
|
1683
|
+
Content-Type: text/event-stream
|
|
1684
|
+
Cache-Control: no-cache
|
|
1685
|
+
Transfer-Encoding: chunked
|
|
1686
|
+
```
|
|
1687
|
+
|
|
1688
|
+
---
|
|
1689
|
+
|
|
1690
|
+
### Event reference
|
|
1691
|
+
|
|
1692
|
+
Every event shares the same envelope:
|
|
1693
|
+
|
|
1694
|
+
```json
|
|
1695
|
+
{
|
|
1696
|
+
"type": "node.added",
|
|
1697
|
+
"ts": 1716307200123,
|
|
1698
|
+
"data": { ... }
|
|
1699
|
+
}
|
|
1700
|
+
```
|
|
1701
|
+
|
|
1702
|
+
| Field | Type | Description |
|
|
1703
|
+
|-------|------|-------------|
|
|
1704
|
+
| `type` | string | Dot-namespaced event name (see table below) |
|
|
1705
|
+
| `ts` | integer | Unix timestamp in milliseconds |
|
|
1706
|
+
| `data` | object | Event-specific payload (see below) |
|
|
1707
|
+
|
|
1708
|
+
**Node payload** (for `node.*`, `skeleton.*`, `light.*`, `camera.*`):
|
|
1709
|
+
|
|
1710
|
+
```json
|
|
1711
|
+
{
|
|
1712
|
+
"node_id": "0x1a2b3c4d",
|
|
1713
|
+
"node_name": "Genesis 9",
|
|
1714
|
+
"node_type": "DzFigure"
|
|
1715
|
+
}
|
|
1716
|
+
```
|
|
1717
|
+
|
|
1718
|
+
`node_id` is a session-stable hex pointer. Use it to correlate add/remove events for the same node within a session.
|
|
1719
|
+
|
|
1720
|
+
**Scene-save payload** (`scene.saving`, `scene.saved`):
|
|
1721
|
+
|
|
1722
|
+
```json
|
|
1723
|
+
{ "filename": "/Users/me/Documents/MyScene.duf" }
|
|
1724
|
+
```
|
|
1725
|
+
|
|
1726
|
+
**Time payload** (`time.changed`):
|
|
1727
|
+
|
|
1728
|
+
```json
|
|
1729
|
+
{ "time_ticks": 4800, "time_secs": 1.0 }
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
`time.changed` is debounced — during playback it fires at most once per 150 ms regardless of frame rate.
|
|
1733
|
+
|
|
1734
|
+
**Empty payload** — all other events (`node.removed`, `selection.*`, `scene.loading`, `scene.loaded`, `scene.cleared`, `playback.*`, `render.*`):
|
|
1735
|
+
|
|
1736
|
+
```json
|
|
1737
|
+
{}
|
|
1738
|
+
```
|
|
1739
|
+
|
|
1740
|
+
**Full event type table:**
|
|
1741
|
+
|
|
1742
|
+
| `type` | Category | Payload fields |
|
|
1743
|
+
|--------|----------|----------------|
|
|
1744
|
+
| `node.added` | `node` | `node_id`, `node_name`, `node_type` |
|
|
1745
|
+
| `node.removed` | `node` | `node_id`, `node_name`, `node_type` |
|
|
1746
|
+
| `skeleton.added` | `skeleton` | `node_id`, `node_name`, `node_type` |
|
|
1747
|
+
| `skeleton.removed` | `skeleton` | `node_id`, `node_name`, `node_type` |
|
|
1748
|
+
| `light.added` | `light` | `node_id`, `node_name`, `node_type` |
|
|
1749
|
+
| `light.removed` | `light` | `node_id`, `node_name`, `node_type` |
|
|
1750
|
+
| `camera.added` | `camera` | `node_id`, `node_name`, `node_type` |
|
|
1751
|
+
| `camera.removed` | `camera` | `node_id`, `node_name`, `node_type` |
|
|
1752
|
+
| `selection.list_changed` | `selection` | _(empty)_ |
|
|
1753
|
+
| `selection.primary_changed` | `selection` | `node_id`, `node_name`, `node_type` (or `{}` if deselected) |
|
|
1754
|
+
| `scene.loading` | `scene` | _(empty)_ |
|
|
1755
|
+
| `scene.loaded` | `scene` | _(empty)_ |
|
|
1756
|
+
| `scene.saving` | `scene` | `filename` |
|
|
1757
|
+
| `scene.saved` | `scene` | `filename` |
|
|
1758
|
+
| `scene.clear_starting` | `scene` | _(empty)_ |
|
|
1759
|
+
| `scene.cleared` | `scene` | _(empty)_ |
|
|
1760
|
+
| `time.changed` | `time` | `time_ticks`, `time_secs` |
|
|
1761
|
+
| `playback.started` | `time` | _(empty)_ |
|
|
1762
|
+
| `playback.finished` | `time` | _(empty)_ |
|
|
1763
|
+
| `render.started` | `render` | _(empty)_ |
|
|
1764
|
+
| `render.finished` | `render` | _(empty)_ |
|
|
1765
|
+
|
|
1766
|
+
---
|
|
1767
|
+
|
|
1768
|
+
### Scene Events — Client Examples
|
|
1769
|
+
|
|
1770
|
+
#### curl
|
|
1771
|
+
|
|
1772
|
+
```bash
|
|
1773
|
+
TOKEN=$(cat ~/.daz3d/dazscriptserver_token.txt)
|
|
1774
|
+
|
|
1775
|
+
# All events
|
|
1776
|
+
curl -N -H "X-API-Token: $TOKEN" http://127.0.0.1:18811/scene/events
|
|
1777
|
+
|
|
1778
|
+
# Node and scene events only
|
|
1779
|
+
curl -N -H "X-API-Token: $TOKEN" \
|
|
1780
|
+
"http://127.0.0.1:18811/scene/events?filter=node,scene"
|
|
1781
|
+
```
|
|
1782
|
+
|
|
1783
|
+
#### Python — requests (no extra dependencies)
|
|
1784
|
+
|
|
1785
|
+
```python
|
|
1786
|
+
import requests, json, os
|
|
1787
|
+
|
|
1788
|
+
token = open(os.path.expanduser("~/.daz3d/dazscriptserver_token.txt")).read().strip()
|
|
1789
|
+
|
|
1790
|
+
with requests.get(
|
|
1791
|
+
"http://127.0.0.1:18811/scene/events",
|
|
1792
|
+
headers={"X-API-Token": token},
|
|
1793
|
+
stream=True,
|
|
1794
|
+
timeout=None,
|
|
1795
|
+
) as resp:
|
|
1796
|
+
resp.raise_for_status()
|
|
1797
|
+
for raw in resp.iter_lines():
|
|
1798
|
+
if not raw or raw.startswith(b":"): # skip keepalives and blank lines
|
|
1799
|
+
continue
|
|
1800
|
+
if raw.startswith(b"data: "):
|
|
1801
|
+
event = json.loads(raw[6:])
|
|
1802
|
+
print(f"{event['type']}: {event['data']}")
|
|
1803
|
+
```
|
|
1804
|
+
|
|
1805
|
+
#### Python — sseclient (cleaner API)
|
|
1806
|
+
|
|
1807
|
+
```bash
|
|
1808
|
+
pip install sseclient-py
|
|
1809
|
+
```
|
|
1810
|
+
|
|
1811
|
+
```python
|
|
1812
|
+
import sseclient, requests, os
|
|
1813
|
+
|
|
1814
|
+
token = open(os.path.expanduser("~/.daz3d/dazscriptserver_token.txt")).read().strip()
|
|
1815
|
+
|
|
1816
|
+
response = requests.get(
|
|
1817
|
+
"http://127.0.0.1:18811/scene/events?filter=node,selection,scene",
|
|
1818
|
+
headers={"X-API-Token": token},
|
|
1819
|
+
stream=True,
|
|
1820
|
+
)
|
|
1821
|
+
client = sseclient.SSEClient(response)
|
|
1822
|
+
|
|
1823
|
+
for event in client.events():
|
|
1824
|
+
import json
|
|
1825
|
+
data = json.loads(event.data)
|
|
1826
|
+
print(data["type"], data["data"])
|
|
1827
|
+
```
|
|
1828
|
+
|
|
1829
|
+
#### JavaScript — EventSource (browser)
|
|
1830
|
+
|
|
1831
|
+
The browser's built-in `EventSource` does not support custom headers, so pass the token as a query parameter instead (requires auth to accept it — which DazScriptServer does not currently support). Use `fetch` with `ReadableStream` for authenticated SSE from a browser:
|
|
1832
|
+
|
|
1833
|
+
```javascript
|
|
1834
|
+
const token = "your-token-here";
|
|
1835
|
+
|
|
1836
|
+
const response = await fetch(
|
|
1837
|
+
"http://127.0.0.1:18811/scene/events?filter=node,scene",
|
|
1838
|
+
{ headers: { "X-API-Token": token } }
|
|
1839
|
+
);
|
|
1840
|
+
|
|
1841
|
+
const reader = response.body.getReader();
|
|
1842
|
+
const decoder = new TextDecoder();
|
|
1843
|
+
let buffer = "";
|
|
1844
|
+
|
|
1845
|
+
while (true) {
|
|
1846
|
+
const { done, value } = await reader.read();
|
|
1847
|
+
if (done) break;
|
|
1848
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1849
|
+
|
|
1850
|
+
const lines = buffer.split("\n");
|
|
1851
|
+
buffer = lines.pop(); // keep incomplete last line
|
|
1852
|
+
|
|
1853
|
+
for (const line of lines) {
|
|
1854
|
+
if (line.startsWith("data: ")) {
|
|
1855
|
+
const event = JSON.parse(line.slice(6));
|
|
1856
|
+
console.log(event.type, event.data);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
```
|
|
1861
|
+
|
|
1862
|
+
#### Node.js
|
|
1863
|
+
|
|
1864
|
+
```javascript
|
|
1865
|
+
const http = require("http");
|
|
1866
|
+
const fs = require("fs");
|
|
1867
|
+
const os = require("os");
|
|
1868
|
+
|
|
1869
|
+
const token = fs.readFileSync(`${os.homedir()}/.daz3d/dazscriptserver_token.txt`, "utf8").trim();
|
|
1870
|
+
|
|
1871
|
+
const req = http.request(
|
|
1872
|
+
{ hostname: "127.0.0.1", port: 18811, path: "/scene/events", method: "GET",
|
|
1873
|
+
headers: { "X-API-Token": token, Accept: "text/event-stream" } },
|
|
1874
|
+
(res) => {
|
|
1875
|
+
let buf = "";
|
|
1876
|
+
res.on("data", (chunk) => {
|
|
1877
|
+
buf += chunk.toString();
|
|
1878
|
+
const lines = buf.split("\n");
|
|
1879
|
+
buf = lines.pop();
|
|
1880
|
+
for (const line of lines) {
|
|
1881
|
+
if (line.startsWith("data: ")) {
|
|
1882
|
+
const event = JSON.parse(line.slice(6));
|
|
1883
|
+
console.log(event.type, event.data);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
});
|
|
1887
|
+
}
|
|
1888
|
+
);
|
|
1889
|
+
req.end();
|
|
1890
|
+
```
|
|
1891
|
+
|
|
1892
|
+
---
|
|
1893
|
+
|
|
1894
|
+
### Notes
|
|
1895
|
+
|
|
1896
|
+
- **Multiple concurrent subscribers** are supported — each `GET /scene/events` request is independent.
|
|
1897
|
+
- **High-frequency signals** (`timeChanging` during playback, `nodeSelectionListChanged` during multi-select) are debounced before dispatch so clients are not flooded.
|
|
1898
|
+
- **Server stop** closes all open SSE connections cleanly; clients should reconnect automatically.
|
|
1899
|
+
- **Reverse proxy:** If using nginx in front of the server, add `proxy_buffering off` and `proxy_http_version 1.1` to the SSE location block (see [Reverse Proxy Setup](#reverse-proxy-setup-https)).
|
|
1900
|
+
|
|
1901
|
+
---
|
|
1902
|
+
|
|
1903
|
+
## 🔒 Security Features
|
|
1904
|
+
|
|
1905
|
+
### Built-In Security
|
|
1906
|
+
|
|
1907
|
+
- **Cryptographically Secure Tokens** - 128-bit entropy using OS crypto APIs
|
|
1908
|
+
- **IP Whitelist** - Exact IP matching for access control
|
|
1909
|
+
- **Per-IP Rate Limiting** - Sliding window algorithm prevents brute force
|
|
1910
|
+
- **Input Validation** - Request body, script size, and file path validation
|
|
1911
|
+
- **Audit Logging** - All requests, auth failures, and blocked IPs logged
|
|
1912
|
+
- **Concurrent Request Limiting** - Prevents resource exhaustion attacks
|
|
1913
|
+
- **File Permissions** - Automatic `chmod 600` on token files (Unix/macOS)
|
|
1914
|
+
|
|
1915
|
+
### What's NOT Included
|
|
1916
|
+
|
|
1917
|
+
- **HTTPS/TLS** - Use a reverse proxy (nginx, Apache) for encryption
|
|
1918
|
+
- **X-Forwarded-For** - IP whitelist uses direct TCP connection IP only
|
|
1919
|
+
- **User Accounts** - Single token for all authenticated access
|
|
1920
|
+
- **Persistent Sessions** - Each request is independently authenticated
|
|
1921
|
+
|
|
1922
|
+
---
|
|
1923
|
+
|
|
1924
|
+
## 🛡️ Security Best Practices
|
|
1925
|
+
|
|
1926
|
+
### Localhost-Only Access (Most Secure)
|
|
1927
|
+
|
|
1928
|
+
For controlling DAZ Studio from the same machine:
|
|
1929
|
+
|
|
1930
|
+
1. ✅ Keep host set to `127.0.0.1` (default)
|
|
1931
|
+
2. ✅ Keep authentication enabled (default)
|
|
1932
|
+
3. ✅ Protect token file (`~/.daz3d/dazscriptserver_token.txt`)
|
|
1933
|
+
4. ℹ️ IP whitelist and rate limiting are optional
|
|
1934
|
+
|
|
1935
|
+
### Network Access (Remote Clients)
|
|
1936
|
+
|
|
1937
|
+
For controlling DAZ Studio from other machines:
|
|
1938
|
+
|
|
1939
|
+
1. ✅ Change host to `0.0.0.0` (accept external connections)
|
|
1940
|
+
2. ✅ **Required:** Enable IP whitelist with specific allowed IPs
|
|
1941
|
+
3. ✅ **Required:** Keep authentication enabled
|
|
1942
|
+
4. ✅ **Recommended:** Enable rate limiting (e.g., 60 requests / 60 seconds)
|
|
1943
|
+
5. ✅ **Recommended:** Use firewall rules to restrict port access
|
|
1944
|
+
6. ⚠️ **Never** expose to the public internet without additional security (VPN, reverse proxy with HTTPS)
|
|
1945
|
+
|
|
1946
|
+
### Token Security
|
|
1947
|
+
|
|
1948
|
+
| Do | Don't |
|
|
1949
|
+
|----|-------|
|
|
1950
|
+
| ✅ Treat token like a password | ❌ Commit to version control |
|
|
1951
|
+
| ✅ Copy from UI or token file | ❌ Share publicly or in logs |
|
|
1952
|
+
| ✅ Use "Regenerate" if compromised | ❌ Email or message token |
|
|
1953
|
+
| ✅ Set `chmod 600` on Unix/macOS | ❌ Use same token across environments |
|
|
1954
|
+
| ✅ Restrict file access on Windows | ❌ Disable auth on untrusted networks |
|
|
1955
|
+
|
|
1956
|
+
**Token File Locations:**
|
|
1957
|
+
- Unix/macOS: `~/.daz3d/dazscriptserver_token.txt`
|
|
1958
|
+
- Windows: `%USERPROFILE%\.daz3d\dazscriptserver_token.txt`
|
|
1959
|
+
|
|
1960
|
+
### Monitoring & Auditing
|
|
1961
|
+
|
|
1962
|
+
- ✅ Check request log regularly for suspicious activity
|
|
1963
|
+
- ✅ Monitor `/metrics` for high failure rates or auth failures
|
|
1964
|
+
- ✅ Set up alerts for unusual patterns (rate limit violations, auth failures)
|
|
1965
|
+
- ✅ Review BLOCKED and AUTH FAILED entries in the log
|
|
1966
|
+
|
|
1967
|
+
---
|
|
1968
|
+
|
|
1969
|
+
## 💻 Client Examples
|
|
1970
|
+
|
|
1971
|
+
The repository includes example clients in multiple languages.
|
|
1972
|
+
|
|
1973
|
+
### Python — dazpy SDK
|
|
1974
|
+
|
|
1975
|
+
**Install:**
|
|
1976
|
+
```bash
|
|
1977
|
+
pip install dazpy-*.whl # from the release page
|
|
1978
|
+
# or: pip install -e . # from source
|
|
1979
|
+
```
|
|
1980
|
+
|
|
1981
|
+
**Files:**
|
|
1982
|
+
- `tests_dazpy.py` — SDK unit tests (mock-based, no server needed)
|
|
1983
|
+
- `tests_dazpy_integration.py` — SDK integration tests (requires running server)
|
|
1984
|
+
|
|
1985
|
+
**Usage:**
|
|
1986
|
+
```python
|
|
1987
|
+
from dazpy import DazClient, DazScene
|
|
1988
|
+
|
|
1989
|
+
client = DazClient() # auto-loads token
|
|
1990
|
+
scene = DazScene(client)
|
|
1991
|
+
|
|
1992
|
+
# Inspect scene
|
|
1993
|
+
print(scene.num_nodes(), "nodes")
|
|
1994
|
+
for node in scene.nodes():
|
|
1995
|
+
print(node.label(), node.position())
|
|
1996
|
+
|
|
1997
|
+
# Pose a figure
|
|
1998
|
+
figure = scene.find_skeleton_by_label("Genesis 9")
|
|
1999
|
+
with scene.undo("T-pose arms"):
|
|
2000
|
+
figure.find_bone("lShldrBend").set_local_rotation(0, 0, -90)
|
|
2001
|
+
figure.find_bone("rShldrBend").set_local_rotation(0, 0, 90)
|
|
2002
|
+
|
|
2003
|
+
# Adjust morphs
|
|
2004
|
+
smile = figure.find_modifier("PHMSmileOpen")
|
|
2005
|
+
smile.set_value(0.75)
|
|
2006
|
+
|
|
2007
|
+
# Render asynchronously
|
|
2008
|
+
from dazpy import execute_long
|
|
2009
|
+
result = execute_long(client, "App.getRenderMgr().doRender(); return 'done';",
|
|
2010
|
+
timeout=300.0)
|
|
2011
|
+
```
|
|
2012
|
+
|
|
2013
|
+
### Python — Raw HTTP
|
|
2014
|
+
|
|
2015
|
+
**Files:**
|
|
2016
|
+
- `test-simple.py` — Basic raw-HTTP client
|
|
2017
|
+
- `tests.py` — Comprehensive server integration test suite (72 tests)
|
|
2018
|
+
- `test-performance.py` — Performance benchmarks and load tests
|
|
2019
|
+
|
|
2020
|
+
**Usage:**
|
|
2021
|
+
```python
|
|
2022
|
+
import requests
|
|
2023
|
+
import os
|
|
2024
|
+
|
|
2025
|
+
token_path = os.path.expanduser("~/.daz3d/dazscriptserver_token.txt")
|
|
2026
|
+
with open(token_path) as f:
|
|
2027
|
+
token = f.read().strip()
|
|
2028
|
+
|
|
2029
|
+
response = requests.post(
|
|
2030
|
+
"http://127.0.0.1:18811/execute",
|
|
2031
|
+
headers={"X-API-Token": token},
|
|
2032
|
+
json={"script": "(function(){ return 'Hello!'; })()", "args": {}}
|
|
2033
|
+
)
|
|
2034
|
+
print(response.json())
|
|
2035
|
+
```
|
|
2036
|
+
|
|
2037
|
+
### JavaScript/Node.js
|
|
2038
|
+
|
|
2039
|
+
**File:** `test-client.js`
|
|
2040
|
+
|
|
2041
|
+
**Requirements:** Node.js 18+ (built-in fetch) or `npm install node-fetch`
|
|
2042
|
+
|
|
2043
|
+
**Usage:**
|
|
2044
|
+
```javascript
|
|
2045
|
+
const fs = require('fs');
|
|
2046
|
+
const os = require('os');
|
|
2047
|
+
|
|
2048
|
+
const tokenPath = `${os.homedir()}/.daz3d/dazscriptserver_token.txt`;
|
|
2049
|
+
const token = fs.readFileSync(tokenPath, 'utf8').trim();
|
|
2050
|
+
|
|
2051
|
+
const response = await fetch('http://127.0.0.1:18811/execute', {
|
|
2052
|
+
method: 'POST',
|
|
2053
|
+
headers: {
|
|
2054
|
+
'Content-Type': 'application/json',
|
|
2055
|
+
'X-API-Token': token
|
|
2056
|
+
},
|
|
2057
|
+
body: JSON.stringify({
|
|
2058
|
+
script: "(function(){ return 'Hello from Node.js!'; })()"
|
|
2059
|
+
})
|
|
2060
|
+
});
|
|
2061
|
+
|
|
2062
|
+
const result = await response.json();
|
|
2063
|
+
console.log(result);
|
|
2064
|
+
```
|
|
2065
|
+
|
|
2066
|
+
### PowerShell
|
|
2067
|
+
|
|
2068
|
+
**File:** `test-client.ps1`
|
|
2069
|
+
|
|
2070
|
+
**Compatible:** PowerShell 5.1+ and PowerShell Core 6+
|
|
2071
|
+
|
|
2072
|
+
**Usage:**
|
|
2073
|
+
```powershell
|
|
2074
|
+
$tokenPath = "$env:USERPROFILE\.daz3d\dazscriptserver_token.txt"
|
|
2075
|
+
$token = Get-Content $tokenPath
|
|
2076
|
+
|
|
2077
|
+
$body = @{
|
|
2078
|
+
script = "(function(){ return 'Hello there from PowerShell!'; })()"
|
|
2079
|
+
} | ConvertTo-Json
|
|
2080
|
+
|
|
2081
|
+
$response = Invoke-RestMethod `
|
|
2082
|
+
-Uri "http://127.0.0.1:18811/execute" `
|
|
2083
|
+
-Method Post `
|
|
2084
|
+
-Headers @{"X-API-Token" = $token} `
|
|
2085
|
+
-ContentType "application/json" `
|
|
2086
|
+
-Body $body
|
|
2087
|
+
|
|
2088
|
+
$response
|
|
2089
|
+
```
|
|
2090
|
+
|
|
2091
|
+
**Running examples:**
|
|
2092
|
+
```bash
|
|
2093
|
+
# dazpy unit tests (no server needed)
|
|
2094
|
+
python tests_dazpy.py
|
|
2095
|
+
|
|
2096
|
+
# dazpy integration tests (requires server running)
|
|
2097
|
+
python tests_dazpy_integration.py
|
|
2098
|
+
|
|
2099
|
+
# Server integration tests
|
|
2100
|
+
python tests.py
|
|
2101
|
+
|
|
2102
|
+
# Python smoke tests
|
|
2103
|
+
python test-simple.py
|
|
2104
|
+
|
|
2105
|
+
# Performance benchmarks and load tests
|
|
2106
|
+
python test-performance.py
|
|
2107
|
+
python test-performance.py --quick # fewer iterations, suitable for CI
|
|
2108
|
+
|
|
2109
|
+
# Node.js
|
|
2110
|
+
node test-client.js
|
|
2111
|
+
|
|
2112
|
+
# PowerShell
|
|
2113
|
+
powershell -ExecutionPolicy Bypass -File test-client.ps1
|
|
2114
|
+
# Or PowerShell Core:
|
|
2115
|
+
pwsh test-client.ps1
|
|
2116
|
+
```
|
|
2117
|
+
|
|
2118
|
+
All examples include error handling, argument passing, and output capture.
|
|
2119
|
+
|
|
2120
|
+
---
|
|
2121
|
+
|
|
2122
|
+
## Writing Scripts
|
|
2123
|
+
|
|
2124
|
+
### Accessing Arguments
|
|
2125
|
+
|
|
2126
|
+
Arguments are available via `getArguments()[0]`:
|
|
2127
|
+
|
|
2128
|
+
```javascript
|
|
2129
|
+
var args = getArguments()[0];
|
|
2130
|
+
print("Hello, " + args.name);
|
|
2131
|
+
return args.value * 2;
|
|
2132
|
+
```
|
|
2133
|
+
|
|
2134
|
+
### Returning Values
|
|
2135
|
+
|
|
2136
|
+
Wrap scripts in an IIFE (Immediately Invoked Function Expression):
|
|
2137
|
+
|
|
2138
|
+
```javascript
|
|
2139
|
+
(function(){
|
|
2140
|
+
var node = Scene.findNodeByLabel("FN Ethan");
|
|
2141
|
+
return {
|
|
2142
|
+
label: node.getLabel(),
|
|
2143
|
+
position: node.getWSPos()
|
|
2144
|
+
};
|
|
2145
|
+
})()
|
|
2146
|
+
```
|
|
2147
|
+
|
|
2148
|
+
### Error Handling
|
|
2149
|
+
|
|
2150
|
+
Throw errors to populate the `error` field:
|
|
2151
|
+
|
|
2152
|
+
```javascript
|
|
2153
|
+
var args = getArguments()[0];
|
|
2154
|
+
if (!args.label) {
|
|
2155
|
+
throw new Error("label is required");
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
var node = Scene.findNodeByLabel(args.label);
|
|
2159
|
+
if (!node) {
|
|
2160
|
+
throw "Node not found: " + args.label;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
return node.getLabel();
|
|
2164
|
+
```
|
|
2165
|
+
|
|
2166
|
+
### Using Include
|
|
2167
|
+
|
|
2168
|
+
Use `scriptFile` (not inline `script`) for scripts that use `include()`:
|
|
2169
|
+
|
|
2170
|
+
```javascript
|
|
2171
|
+
// File: /path/to/main.dsa
|
|
2172
|
+
var includeDir = DzFile(getScriptFileName()).path();
|
|
2173
|
+
include(includeDir + "/utils.dsa");
|
|
2174
|
+
|
|
2175
|
+
var args = getArguments()[0];
|
|
2176
|
+
return myUtilFunction(args);
|
|
2177
|
+
```
|
|
2178
|
+
|
|
2179
|
+
**Request:**
|
|
2180
|
+
```json
|
|
2181
|
+
{
|
|
2182
|
+
"scriptFile": "/path/to/main.dsa",
|
|
2183
|
+
"args": { "value": 42 }
|
|
2184
|
+
}
|
|
2185
|
+
```
|
|
2186
|
+
|
|
2187
|
+
### Script Registry Pattern
|
|
2188
|
+
|
|
2189
|
+
Register once, call many times:
|
|
2190
|
+
|
|
2191
|
+
```python
|
|
2192
|
+
import requests
|
|
2193
|
+
import os
|
|
2194
|
+
|
|
2195
|
+
token_path = os.path.expanduser("~/.daz3d/dazscriptserver_token.txt")
|
|
2196
|
+
with open(token_path) as f:
|
|
2197
|
+
token = f.read().strip()
|
|
2198
|
+
|
|
2199
|
+
BASE = "http://127.0.0.1:18811"
|
|
2200
|
+
HEADERS = {"X-API-Token": token}
|
|
2201
|
+
|
|
2202
|
+
def register_scripts():
|
|
2203
|
+
"""Register all scripts on startup or after 404."""
|
|
2204
|
+
requests.post(f"{BASE}/scripts/register", headers=HEADERS, json={
|
|
2205
|
+
"name": "node-count",
|
|
2206
|
+
"script": "(function(){ return Scene.getNumNodes(); })()"
|
|
2207
|
+
})
|
|
2208
|
+
|
|
2209
|
+
def call_script(name, args=None):
|
|
2210
|
+
"""Execute a registered script by name."""
|
|
2211
|
+
r = requests.post(
|
|
2212
|
+
f"{BASE}/scripts/{name}/execute",
|
|
2213
|
+
headers=HEADERS,
|
|
2214
|
+
json={"args": args or {}}
|
|
2215
|
+
)
|
|
2216
|
+
|
|
2217
|
+
# Handle DAZ Studio restart (registry cleared)
|
|
2218
|
+
if r.status_code == 404:
|
|
2219
|
+
register_scripts()
|
|
2220
|
+
r = requests.post(
|
|
2221
|
+
f"{BASE}/scripts/{name}/execute",
|
|
2222
|
+
headers=HEADERS,
|
|
2223
|
+
json={"args": args or {}}
|
|
2224
|
+
)
|
|
2225
|
+
|
|
2226
|
+
r.raise_for_status()
|
|
2227
|
+
return r.json()["result"]
|
|
2228
|
+
|
|
2229
|
+
# Initialize
|
|
2230
|
+
register_scripts()
|
|
2231
|
+
|
|
2232
|
+
# Use
|
|
2233
|
+
node_count = call_script("node-count")
|
|
2234
|
+
print(f"Scene has {node_count} nodes")
|
|
2235
|
+
```
|
|
2236
|
+
|
|
2237
|
+
### Rendering Example
|
|
2238
|
+
|
|
2239
|
+
```javascript
|
|
2240
|
+
(function(){
|
|
2241
|
+
var args = getArguments()[0];
|
|
2242
|
+
|
|
2243
|
+
// Load scene
|
|
2244
|
+
Scene.load(args.scenePath);
|
|
2245
|
+
|
|
2246
|
+
// Configure render settings
|
|
2247
|
+
var renderMgr = App.getRenderMgr();
|
|
2248
|
+
var renderOptions = renderMgr.getRenderOptions();
|
|
2249
|
+
renderOptions.setImageFilename(args.outputPath);
|
|
2250
|
+
renderOptions.setImageSize(args.width, args.height);
|
|
2251
|
+
|
|
2252
|
+
// Trigger render (blocks until complete)
|
|
2253
|
+
renderMgr.doRender(renderOptions);
|
|
2254
|
+
|
|
2255
|
+
return { status: "complete", output: args.outputPath };
|
|
2256
|
+
})()
|
|
2257
|
+
```
|
|
2258
|
+
|
|
2259
|
+
**Note:** Renders block the request until complete. Use appropriate timeout settings (30-300 seconds).
|
|
2260
|
+
|
|
2261
|
+
---
|
|
2262
|
+
|
|
2263
|
+
## 🔧 Troubleshooting
|
|
2264
|
+
|
|
2265
|
+
### Server Won't Start
|
|
2266
|
+
|
|
2267
|
+
**"Port already in use" or "Failed to bind":**
|
|
2268
|
+
- Another application is using the port
|
|
2269
|
+
- Check if another DAZ Studio instance is running the plugin
|
|
2270
|
+
- Try a different port number
|
|
2271
|
+
- Check what's using the port:
|
|
2272
|
+
- Windows: `netstat -ano | findstr :18811`
|
|
2273
|
+
- macOS/Linux: `lsof -i :18811` or `netstat -an | grep 18811`
|
|
2274
|
+
|
|
2275
|
+
**Server starts but immediately stops:**
|
|
2276
|
+
- Check DAZ Studio log for error messages
|
|
2277
|
+
- Verify permissions to bind to the configured host/port
|
|
2278
|
+
- Ports < 1024 require root privileges (use ports > 1024)
|
|
2279
|
+
|
|
2280
|
+
### Connection Refused
|
|
2281
|
+
|
|
2282
|
+
**Cannot connect from localhost:**
|
|
2283
|
+
- Verify server is running (check UI status)
|
|
2284
|
+
- Verify correct port (default: 18811)
|
|
2285
|
+
- Check host is `127.0.0.1` or `0.0.0.0`
|
|
2286
|
+
- Firewall may be blocking (add exception for DAZ Studio)
|
|
2287
|
+
|
|
2288
|
+
**Cannot connect from remote machine:**
|
|
2289
|
+
- Verify host is `0.0.0.0` (not `127.0.0.1`)
|
|
2290
|
+
- Check IP whitelist includes client IP
|
|
2291
|
+
- Verify firewall allows incoming connections on the port
|
|
2292
|
+
- Verify network routing between client and server
|
|
2293
|
+
|
|
2294
|
+
### Authentication Errors (HTTP 401)
|
|
2295
|
+
|
|
2296
|
+
**"Invalid or missing authentication token":**
|
|
2297
|
+
- Verify token file exists: `~/.daz3d/dazscriptserver_token.txt`
|
|
2298
|
+
- Copy token exactly from UI or file (no extra spaces)
|
|
2299
|
+
- Use correct header: `X-API-Token: <token>` or `Authorization: Bearer <token>`
|
|
2300
|
+
- If token file is corrupted, use "Regenerate" button
|
|
2301
|
+
- Verify authentication is enabled in UI
|
|
2302
|
+
|
|
2303
|
+
### HTTP 403 Forbidden
|
|
2304
|
+
|
|
2305
|
+
**"IP not whitelisted":**
|
|
2306
|
+
- IP whitelist is enabled and your IP is not in the list
|
|
2307
|
+
- Add client IP to whitelist (comma-separated)
|
|
2308
|
+
- Check actual IP (may differ due to NAT/proxy)
|
|
2309
|
+
- Temporarily disable IP whitelist for testing
|
|
2310
|
+
|
|
2311
|
+
### HTTP 429 Too Many Requests
|
|
2312
|
+
|
|
2313
|
+
**"Rate limit exceeded":**
|
|
2314
|
+
- Per-IP rate limit exceeded
|
|
2315
|
+
- Wait for time window to expire (default: 60 seconds)
|
|
2316
|
+
- Increase max requests or time window
|
|
2317
|
+
- Temporarily disable rate limiting for testing
|
|
2318
|
+
|
|
2319
|
+
**"Maximum concurrent requests limit reached":**
|
|
2320
|
+
- Too many scripts running simultaneously
|
|
2321
|
+
- Wait for requests to complete
|
|
2322
|
+
- Increase max concurrent requests
|
|
2323
|
+
- Optimize scripts for faster execution
|
|
2324
|
+
- Add delays between requests in client
|
|
2325
|
+
|
|
2326
|
+
### HTTP 413 Payload Too Large
|
|
2327
|
+
|
|
2328
|
+
**"Request body too large":**
|
|
2329
|
+
- Request exceeds configured max (default: 5MB)
|
|
2330
|
+
- Increase max body size in Advanced Limits
|
|
2331
|
+
- For large scripts, use `scriptFile` instead of inline `script`
|
|
2332
|
+
|
|
2333
|
+
### Script Execution Errors
|
|
2334
|
+
|
|
2335
|
+
**"Script execution failed" or error in response:**
|
|
2336
|
+
- Check `error` field for details (includes line number)
|
|
2337
|
+
- Verify script syntax is valid DazScript
|
|
2338
|
+
- Check referenced files/assets exist
|
|
2339
|
+
- Review `output` field for print statements
|
|
2340
|
+
- Test script manually in DAZ Studio Script IDE first
|
|
2341
|
+
|
|
2342
|
+
**Script times out:**
|
|
2343
|
+
- Script exceeds timeout (default: 30 seconds)
|
|
2344
|
+
- Increase timeout in configuration (max: 300 seconds)
|
|
2345
|
+
- Optimize script performance
|
|
2346
|
+
- Break long operations into multiple smaller requests
|
|
2347
|
+
|
|
2348
|
+
### Token File Issues
|
|
2349
|
+
|
|
2350
|
+
**Token file not created:**
|
|
2351
|
+
- Plugin may lack write permissions to home directory
|
|
2352
|
+
- Manually create `~/.daz3d/` directory
|
|
2353
|
+
- Windows: `%USERPROFILE%\.daz3d\`
|
|
2354
|
+
- Check DAZ Studio log for permission errors
|
|
2355
|
+
|
|
2356
|
+
**Token file permissions warning (Unix/macOS):**
|
|
2357
|
+
- Plugin automatically sets `chmod 600`
|
|
2358
|
+
- If warning persists: `chmod 600 ~/.daz3d/dazscriptserver_token.txt`
|
|
2359
|
+
|
|
2360
|
+
---
|
|
2361
|
+
|
|
2362
|
+
## ❓ Frequently Asked Questions
|
|
2363
|
+
|
|
2364
|
+
### Is this safe to use?
|
|
2365
|
+
|
|
2366
|
+
The plugin is designed with security in mind:
|
|
2367
|
+
- ✅ Cryptographically secure API tokens
|
|
2368
|
+
- ✅ Optional IP whitelist and rate limiting
|
|
2369
|
+
- ✅ Input validation and size limits
|
|
2370
|
+
- ✅ Audit logging of all requests
|
|
2371
|
+
|
|
2372
|
+
**However:** Any client with a valid token can execute arbitrary DazScript code with full access to your DAZ Studio scene, file system (within script permissions), and system resources. **Treat your API token like a password** and only share it with trusted applications.
|
|
2373
|
+
|
|
2374
|
+
### Can I use this in production?
|
|
2375
|
+
|
|
2376
|
+
**Yes!** This plugin is production-ready:
|
|
2377
|
+
- Concurrent request limiting prevents resource exhaustion
|
|
2378
|
+
- Rate limiting prevents abuse
|
|
2379
|
+
- Health and metrics endpoints for monitoring
|
|
2380
|
+
- Configurable timeouts and limits
|
|
2381
|
+
- Comprehensive error handling and logging
|
|
2382
|
+
|
|
2383
|
+
Many users run this plugin 24/7 for batch rendering, asset processing, and integration workflows.
|
|
2384
|
+
|
|
2385
|
+
### What's the performance impact?
|
|
2386
|
+
|
|
2387
|
+
**Idle:** Negligible CPU usage when not processing requests.
|
|
2388
|
+
|
|
2389
|
+
**Under load:** Performance depends on your scripts. The plugin adds < 10ms overhead per request. The limiting factor is usually DazScript execution time and DAZ Studio's single-threaded scene graph operations.
|
|
2390
|
+
|
|
2391
|
+
### Can I run multiple instances?
|
|
2392
|
+
|
|
2393
|
+
No. Each DAZ Studio process can only load the plugin once.
|
|
2394
|
+
|
|
2395
|
+
**Alternatives:**
|
|
2396
|
+
- Run multiple DAZ Studio instances on different ports (separate processes)
|
|
2397
|
+
- Use concurrent request limit for multiple simultaneous requests in one instance
|
|
2398
|
+
|
|
2399
|
+
### Does this work with DAZ Studio CLI/headless mode?
|
|
2400
|
+
|
|
2401
|
+
The plugin requires DAZ Studio GUI (it's a pane plugin). For headless automation, run DAZ Studio in a virtual display environment:
|
|
2402
|
+
- Linux: Xvfb
|
|
2403
|
+
- Windows: Hidden window
|
|
2404
|
+
|
|
2405
|
+
### Can I execute multiple scripts in parallel?
|
|
2406
|
+
|
|
2407
|
+
Yes, up to the configured concurrent request limit (default: 10).
|
|
2408
|
+
|
|
2409
|
+
**Important notes:**
|
|
2410
|
+
- All scripts execute on DAZ Studio's main thread (SDK requirement)
|
|
2411
|
+
- Scripts are executed serially, not truly in parallel
|
|
2412
|
+
- The concurrent limit prevents too many requests from queuing
|
|
2413
|
+
- Heavy scene operations may block other requests
|
|
2414
|
+
|
|
2415
|
+
### What DazScript features are supported?
|
|
2416
|
+
|
|
2417
|
+
**All standard DazScript features work:**
|
|
2418
|
+
- Scene graph manipulation (load, modify, render)
|
|
2419
|
+
- File I/O operations
|
|
2420
|
+
- Include/import of other scripts (use `scriptFile`)
|
|
2421
|
+
- App objects and APIs
|
|
2422
|
+
- Print statements (captured in `output` array)
|
|
2423
|
+
|
|
2424
|
+
The only difference from manual script execution is that arguments are passed via the `args` JSON object instead of command-line parameters.
|
|
2425
|
+
|
|
2426
|
+
### How do I debug scripts?
|
|
2427
|
+
|
|
2428
|
+
1. **Use `print()` statements** - Captured in response `output` array
|
|
2429
|
+
2. **Check the `error` field** - Includes line numbers
|
|
2430
|
+
3. **Test manually first** - Run in DAZ Studio Script IDE
|
|
2431
|
+
4. **Check UI request log** - Shows status and duration
|
|
2432
|
+
5. **Use `request_id`** - Correlate requests between client and server logs
|
|
2433
|
+
|
|
2434
|
+
### Can I trigger renders?
|
|
2435
|
+
|
|
2436
|
+
**Yes!** Execute any DazScript that renders:
|
|
2437
|
+
|
|
2438
|
+
```javascript
|
|
2439
|
+
(function(){
|
|
2440
|
+
var args = getArguments()[0];
|
|
2441
|
+
Scene.load(args.scenePath);
|
|
2442
|
+
|
|
2443
|
+
var renderMgr = App.getRenderMgr();
|
|
2444
|
+
var renderOptions = renderMgr.getRenderOptions();
|
|
2445
|
+
renderOptions.setImageFilename(args.outputPath);
|
|
2446
|
+
|
|
2447
|
+
renderMgr.doRender(renderOptions);
|
|
2448
|
+
return "Render complete";
|
|
2449
|
+
})()
|
|
2450
|
+
```
|
|
2451
|
+
|
|
2452
|
+
**Note:** Renders block the request until complete. Use appropriate timeout settings.
|
|
2453
|
+
|
|
2454
|
+
### How do I upgrade to a new version?
|
|
2455
|
+
|
|
2456
|
+
1. Stop the server in DAZ Studio
|
|
2457
|
+
2. Close DAZ Studio
|
|
2458
|
+
3. Replace the plugin DLL/dylib in plugins folder
|
|
2459
|
+
4. Restart DAZ Studio
|
|
2460
|
+
5. Start the server
|
|
2461
|
+
|
|
2462
|
+
Your API token and settings persist across upgrades (stored separately).
|
|
2463
|
+
|
|
2464
|
+
### Where are settings stored?
|
|
2465
|
+
|
|
2466
|
+
**Settings (QSettings):**
|
|
2467
|
+
- Windows: Registry key `HKEY_CURRENT_USER\Software\DAZ 3D\DazScriptServer`
|
|
2468
|
+
- macOS: `~/Library/Preferences/com.daz3d.DazScriptServer.plist`
|
|
2469
|
+
- Linux: `~/.config/DAZ 3D/DazScriptServer.conf`
|
|
2470
|
+
|
|
2471
|
+
**API Token:**
|
|
2472
|
+
- All platforms: `~/.daz3d/dazscriptserver_token.txt`
|
|
2473
|
+
|
|
2474
|
+
---
|
|
2475
|
+
|
|
2476
|
+
## Advanced Topics
|
|
2477
|
+
|
|
2478
|
+
### Performance Tuning
|
|
2479
|
+
|
|
2480
|
+
**For high-throughput scenarios:**
|
|
2481
|
+
- Increase max concurrent requests (default: 10, max: 50)
|
|
2482
|
+
- Increase timeout for long-running scripts
|
|
2483
|
+
- Enable rate limiting to prevent monopolization
|
|
2484
|
+
- Monitor `/metrics` endpoint for bottlenecks
|
|
2485
|
+
|
|
2486
|
+
**For resource-constrained environments:**
|
|
2487
|
+
- Decrease max concurrent requests
|
|
2488
|
+
- Decrease max body size and script length
|
|
2489
|
+
- Decrease timeout to prevent blocking
|
|
2490
|
+
|
|
2491
|
+
### Integration Patterns
|
|
2492
|
+
|
|
2493
|
+
**Health Check / Polling:**
|
|
2494
|
+
```python
|
|
2495
|
+
import requests
|
|
2496
|
+
import time
|
|
2497
|
+
|
|
2498
|
+
# Wait for server to be ready
|
|
2499
|
+
while True:
|
|
2500
|
+
try:
|
|
2501
|
+
response = requests.get("http://localhost:18811/health")
|
|
2502
|
+
if response.json()["running"]:
|
|
2503
|
+
break
|
|
2504
|
+
except:
|
|
2505
|
+
time.sleep(1)
|
|
2506
|
+
```
|
|
2507
|
+
|
|
2508
|
+
**Batch Processing with Retries:**
|
|
2509
|
+
```python
|
|
2510
|
+
import requests
|
|
2511
|
+
import time
|
|
2512
|
+
|
|
2513
|
+
def process_batch(items, token):
|
|
2514
|
+
for item in items:
|
|
2515
|
+
max_retries = 3
|
|
2516
|
+
for attempt in range(max_retries):
|
|
2517
|
+
try:
|
|
2518
|
+
result = execute_script(item, token)
|
|
2519
|
+
log_success(item, result)
|
|
2520
|
+
break
|
|
2521
|
+
except requests.HTTPError as e:
|
|
2522
|
+
if e.response.status_code == 429:
|
|
2523
|
+
time.sleep(5) # Wait for rate limit
|
|
2524
|
+
elif attempt == max_retries - 1:
|
|
2525
|
+
log_error(item, e)
|
|
2526
|
+
else:
|
|
2527
|
+
continue
|
|
2528
|
+
```
|
|
2529
|
+
|
|
2530
|
+
**Docker/Kubernetes Health Probe:**
|
|
2531
|
+
```bash
|
|
2532
|
+
curl -f http://localhost:18811/health || exit 1
|
|
2533
|
+
```
|
|
2534
|
+
|
|
2535
|
+
### Reverse Proxy Setup (HTTPS)
|
|
2536
|
+
|
|
2537
|
+
For production deployments requiring HTTPS, use a reverse proxy:
|
|
2538
|
+
|
|
2539
|
+
**nginx example:**
|
|
2540
|
+
```nginx
|
|
2541
|
+
server {
|
|
2542
|
+
listen 443 ssl;
|
|
2543
|
+
server_name daz-api.example.com;
|
|
2544
|
+
|
|
2545
|
+
ssl_certificate /path/to/cert.pem;
|
|
2546
|
+
ssl_certificate_key /path/to/key.pem;
|
|
2547
|
+
|
|
2548
|
+
# SSE event stream — must disable buffering so events reach clients immediately
|
|
2549
|
+
location /scene/events {
|
|
2550
|
+
proxy_pass http://127.0.0.1:18811;
|
|
2551
|
+
proxy_set_header Host $host;
|
|
2552
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
2553
|
+
proxy_http_version 1.1; # required for chunked transfer encoding
|
|
2554
|
+
proxy_buffering off; # disable buffering for SSE
|
|
2555
|
+
proxy_cache off;
|
|
2556
|
+
proxy_read_timeout 3600s; # keep connection alive for long-lived streams
|
|
2557
|
+
chunked_transfer_encoding on;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
location / {
|
|
2561
|
+
proxy_pass http://127.0.0.1:18811;
|
|
2562
|
+
proxy_set_header Host $host;
|
|
2563
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
2564
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
2565
|
+
proxy_read_timeout 300s; # For long-running scripts
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
```
|
|
2569
|
+
|
|
2570
|
+
**Important:** The current version does not parse `X-Forwarded-For` headers. IP whitelist and rate limiting will see the reverse proxy's IP, not the original client IP. This is a known limitation.
|
|
2571
|
+
|
|
2572
|
+
### Deployment Checklist
|
|
2573
|
+
|
|
2574
|
+
When deploying to production:
|
|
2575
|
+
|
|
2576
|
+
- [ ] Enable authentication (default)
|
|
2577
|
+
- [ ] Enable IP whitelist with specific allowed IPs
|
|
2578
|
+
- [ ] Enable rate limiting (e.g., 60 requests / 60 seconds)
|
|
2579
|
+
- [ ] Set appropriate concurrent request limit for workload
|
|
2580
|
+
- [ ] Configure timeout based on expected script duration
|
|
2581
|
+
- [ ] Secure token file permissions (`chmod 600` on Unix/macOS)
|
|
2582
|
+
- [ ] Set up monitoring on `/health` and `/metrics` endpoints
|
|
2583
|
+
- [ ] Configure firewall rules to restrict port access
|
|
2584
|
+
- [ ] Test failover behavior (DAZ Studio crashes/restarts)
|
|
2585
|
+
- [ ] Document token rotation procedure for team
|
|
2586
|
+
- [ ] Consider HTTPS via reverse proxy for network exposure
|
|
2587
|
+
|
|
2588
|
+
---
|
|
2589
|
+
|
|
2590
|
+
## Contributing
|
|
2591
|
+
|
|
2592
|
+
Contributions are welcome! See areas for improvement:
|
|
2593
|
+
|
|
2594
|
+
**Security:**
|
|
2595
|
+
- X-Forwarded-For support for reverse proxy deployments
|
|
2596
|
+
- Wildcard IP matching (e.g., `192.168.1.*`)
|
|
2597
|
+
- Per-endpoint authentication policies
|
|
2598
|
+
- Token expiration and automatic rotation
|
|
2599
|
+
|
|
2600
|
+
**Features:**
|
|
2601
|
+
- Per-node property/transform change events in SSE stream (currently scene-level only)
|
|
2602
|
+
- Webhook callbacks — register a URL to receive HTTP POST on scene events (alternative to persistent SSE connection)
|
|
2603
|
+
- Script result caching
|
|
2604
|
+
- Request queueing with priorities
|
|
2605
|
+
- Multiple API tokens with labels
|
|
2606
|
+
- CORS header configuration
|
|
2607
|
+
|
|
2608
|
+
**Observability:**
|
|
2609
|
+
- Prometheus metrics endpoint format
|
|
2610
|
+
- Structured logging (JSON)
|
|
2611
|
+
- Distributed tracing support
|
|
2612
|
+
|
|
2613
|
+
**Developer Experience:**
|
|
2614
|
+
- Pre-built binaries for Windows/macOS
|
|
2615
|
+
- Docker image with DAZ Studio and plugin
|
|
2616
|
+
- More example clients (Go, Rust, C#)
|
|
2617
|
+
|
|
2618
|
+
Open a GitHub issue to request features or report bugs.
|
|
2619
|
+
|
|
2620
|
+
### Development
|
|
2621
|
+
|
|
2622
|
+
For plugin development, see `CLAUDE.md` for detailed architecture notes and development guidelines.
|
|
2623
|
+
|
|
2624
|
+
---
|
|
2625
|
+
|
|
2626
|
+
## License & Attribution
|
|
2627
|
+
|
|
2628
|
+
This project is provided under the terms of the AGPL v3 license for use with DAZ Studio.
|
|
2629
|
+
|
|
2630
|
+
**Dependencies:**
|
|
2631
|
+
- [cpp-httplib](https://github.com/yhirose/cpp-httplib) - Header-only HTTP library (AGPL v3 License)
|
|
2632
|
+
- [DAZ Studio SDK](https://www.daz3d.com/daz-studio-4-5-sdk) - Required for building
|
|
2633
|
+
|
|
2634
|
+
**Platform APIs:**
|
|
2635
|
+
- Windows: CryptoAPI for secure random number generation
|
|
2636
|
+
- macOS/Linux: `/dev/urandom` for secure random number generation
|
|
2637
|
+
|
|
2638
|
+
**Authors:**
|
|
2639
|
+
- Original implementation: Blue Moon Foundry
|
|
2640
|
+
- Production-ready improvements: BMF and Community contributors
|
|
2641
|
+
|
|
2642
|
+
For questions, issues, or feature requests, please open an issue on GitHub.
|