holosplat 0.6.0
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.
- package/README.md +890 -0
- package/bin/holosplat.cjs +374 -0
- package/dist/holosplat.esm.js +766 -0
- package/dist/holosplat.esm.js.map +7 -0
- package/dist/holosplat.iife.js +766 -0
- package/dist/holosplat.iife.js.map +7 -0
- package/holosplat/editor.js +2947 -0
- package/holosplat/index.html +614 -0
- package/holosplat/stats.js +101 -0
- package/package.json +30 -0
- package/server.py +560 -0
- package/src/server.js +198 -0
package/README.md
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
# HoloSplat
|
|
2
|
+
|
|
3
|
+
WebGPU Gaussian Splat viewer with scroll-driven animation, an art-direction editor, and a Node.js server middleware.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [How it works](#how-it-works)
|
|
10
|
+
- [Quick start](#quick-start)
|
|
11
|
+
- [Development server](#development-server)
|
|
12
|
+
- [Embedding a scene](#embedding-a-scene)
|
|
13
|
+
- [Script tag — no build tools](#script-tag--no-build-tools)
|
|
14
|
+
- [Data-attribute auto-init](#data-attribute-auto-init)
|
|
15
|
+
- [ESM / bundler](#esm--bundler)
|
|
16
|
+
- [Scroll-driven animation](#scroll-driven-animation)
|
|
17
|
+
- [HTML structure](#html-structure)
|
|
18
|
+
- [Act types](#act-types)
|
|
19
|
+
- [Captions](#captions)
|
|
20
|
+
- [Callouts](#callouts)
|
|
21
|
+
- [Web page side](#web-page-side)
|
|
22
|
+
- [Styling callouts](#styling-callouts)
|
|
23
|
+
- [Blender workflow](#blender-workflow)
|
|
24
|
+
- [Export script](#export-script)
|
|
25
|
+
- [CONFIG options](#config-options)
|
|
26
|
+
- [Timeline markers](#timeline-markers)
|
|
27
|
+
- [Adding callout anchors](#adding-callout-anchors)
|
|
28
|
+
- [Coordinate systems and GS object](#coordinate-systems-and-gs-object)
|
|
29
|
+
- [Art-direction editor](#art-direction-editor)
|
|
30
|
+
- [Starting the editor](#starting-the-editor)
|
|
31
|
+
- [Editor workflow](#editor-workflow)
|
|
32
|
+
- [hs-config.json reference](#hs-configjson-reference)
|
|
33
|
+
- [Node.js server middleware](#nodejs-server-middleware)
|
|
34
|
+
- [Animated multi-part scenes](#animated-multi-part-scenes)
|
|
35
|
+
- [Building the library](#building-the-library)
|
|
36
|
+
- [JavaScript API reference](#javascript-api-reference)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
A HoloSplat scene has three parts:
|
|
43
|
+
|
|
44
|
+
1. **A splat file** (`.spz`, `.ply`, or `.splat`) — the 3D Gaussian Splat capture.
|
|
45
|
+
2. **An animation JSON** — exported from Blender; contains a per-frame camera path, FOV, timeline markers, and optional callout positions.
|
|
46
|
+
3. **`hs-config.json`** — maps Blender timeline markers to scroll acts, and sets the scroll height for each act. Written by the editor.
|
|
47
|
+
|
|
48
|
+
At runtime, `scrollScene()` maps the visitor's scroll position to a frame number. The player seeks the animation to that frame, sets the camera, and renders the splat.
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
scroll position
|
|
52
|
+
│
|
|
53
|
+
▼
|
|
54
|
+
hs-config.json ← built in the editor
|
|
55
|
+
(acts + heights)
|
|
56
|
+
│
|
|
57
|
+
▼
|
|
58
|
+
frame number
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
animation.json ← exported from Blender
|
|
62
|
+
(eye + forward per frame)
|
|
63
|
+
│
|
|
64
|
+
▼
|
|
65
|
+
camera matrices
|
|
66
|
+
│
|
|
67
|
+
▼
|
|
68
|
+
WebGPU renderer ← splat file (.spz / .ply / .splat)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
# Install (or add to an existing project)
|
|
77
|
+
npm install holosplat
|
|
78
|
+
|
|
79
|
+
# Scaffold editor + dev server into the project root
|
|
80
|
+
npx holosplat init
|
|
81
|
+
|
|
82
|
+
# Start the Python dev server
|
|
83
|
+
python server.py # → http://localhost:8080
|
|
84
|
+
|
|
85
|
+
# In a second terminal, watch-build the library if you're editing src/
|
|
86
|
+
node build.js --watch
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
After `init`, open `http://localhost:8080/holosplat/` for the art-direction editor.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Development server
|
|
94
|
+
|
|
95
|
+
`server.py` is a zero-dependency Python 3 HTTP server that also serves the `/hs-api` routes the editor needs.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python server.py # default port 8080
|
|
99
|
+
python server.py 3000 # custom port
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
It serves:
|
|
103
|
+
|
|
104
|
+
| Path | What |
|
|
105
|
+
|------|------|
|
|
106
|
+
| `/` | Project root (static files) |
|
|
107
|
+
| `/holosplat/` | Art-direction editor UI |
|
|
108
|
+
| `/scenes/` | Scene and animation files |
|
|
109
|
+
| `/hs-api/ls` | Lists loadable files for the editor |
|
|
110
|
+
| `/hs-api/file?path=…` | Read / write files (GET / PUT) |
|
|
111
|
+
|
|
112
|
+
The `scenes/` folder is created automatically if it doesn't exist. Put your `.spz`, `.ply`, `.splat`, and `.json` files there.
|
|
113
|
+
|
|
114
|
+
**WebGPU requires a secure context** — `http://localhost` is fine. `file://` URLs are not.
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Embedding a scene
|
|
119
|
+
|
|
120
|
+
### Script tag — no build tools
|
|
121
|
+
|
|
122
|
+
```html
|
|
123
|
+
<script src="holosplat.iife.js"></script>
|
|
124
|
+
|
|
125
|
+
<div id="viewer" style="width:100%; height:500px"></div>
|
|
126
|
+
|
|
127
|
+
<script>
|
|
128
|
+
HoloSplat.player('#viewer', {
|
|
129
|
+
src: 'https://cdn.example.com/scene.spz',
|
|
130
|
+
});
|
|
131
|
+
</script>
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Data-attribute auto-init
|
|
135
|
+
|
|
136
|
+
Place the `<script>` tag anywhere on the page. Any `data-holosplat` element initialises automatically when the DOM is ready — no JavaScript required.
|
|
137
|
+
|
|
138
|
+
```html
|
|
139
|
+
<script src="holosplat.iife.js"></script>
|
|
140
|
+
|
|
141
|
+
<div
|
|
142
|
+
data-holosplat="https://cdn.example.com/scene.spz"
|
|
143
|
+
data-holosplat-anim="https://cdn.example.com/anim.json"
|
|
144
|
+
style="width:100%; height:500px">
|
|
145
|
+
</div>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### ESM / bundler
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
import { player, scrollScene } from 'holosplat';
|
|
152
|
+
|
|
153
|
+
const p = player('#viewer', {
|
|
154
|
+
src: '/scenes/scene.spz',
|
|
155
|
+
animation: '/scenes/anim.json',
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `player()` options
|
|
160
|
+
|
|
161
|
+
| Option | Type | Default | Description |
|
|
162
|
+
|--------|------|---------|-------------|
|
|
163
|
+
| `src` | string | — | URL of `.spz`, `.ply`, or `.splat` file |
|
|
164
|
+
| `animation` | string | — | URL of animation JSON |
|
|
165
|
+
| `background` | string \| number[] | `'transparent'` | `'#rrggbb'`, `'transparent'`, or `[r,g,b,a]` (0–1) |
|
|
166
|
+
| `fov` | number | 60 | Vertical field of view, degrees |
|
|
167
|
+
| `near` | number | 0.1 | Near clip plane |
|
|
168
|
+
| `far` | number | 2000 | Far clip plane |
|
|
169
|
+
| `splatScale` | number | 1 | Global splat size multiplier |
|
|
170
|
+
| `autoRotate` | boolean | false | Slow continuous orbit when idle |
|
|
171
|
+
| `flipY` | boolean | false | 180° X-axis flip (for COLMAP/OpenCV captured scenes) |
|
|
172
|
+
| `onLoad` | function | — | Called when scene is ready |
|
|
173
|
+
| `onProgress` | function(0..1) | — | Called during fetch |
|
|
174
|
+
| `onError` | function(Error) | — | Called on any error |
|
|
175
|
+
|
|
176
|
+
### `player()` API
|
|
177
|
+
|
|
178
|
+
```js
|
|
179
|
+
const p = player('#viewer', { src: '…' });
|
|
180
|
+
|
|
181
|
+
p.load(url) // swap scene
|
|
182
|
+
p.loadAnim(url) // swap animation
|
|
183
|
+
p.destroy() // stop + remove all DOM
|
|
184
|
+
p.setBackground(bg)
|
|
185
|
+
p.setSplatScale(s)
|
|
186
|
+
p.setAutoRotate(bool)
|
|
187
|
+
p.setFlipY(bool)
|
|
188
|
+
p.setAnimationPaused(bool)
|
|
189
|
+
p.setCameraFree(bool) // let user orbit while animation is attached
|
|
190
|
+
p.resetCamera() // re-fit camera to scene bounds, reset angle
|
|
191
|
+
p.focusCamera() // re-fit camera to scene bounds, keep angle
|
|
192
|
+
|
|
193
|
+
p.camera // OrbitCamera instance
|
|
194
|
+
p.animation // Animation instance (or null)
|
|
195
|
+
p.callout('id') // returns the HTMLElement for a callout card
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Scroll-driven animation
|
|
201
|
+
|
|
202
|
+
### HTML structure
|
|
203
|
+
|
|
204
|
+
```html
|
|
205
|
+
<div class="hs-scene">
|
|
206
|
+
|
|
207
|
+
<!-- The player mounts here -->
|
|
208
|
+
<div class="hs-stage"
|
|
209
|
+
data-holosplat="/scenes/scene.spz"
|
|
210
|
+
data-holosplat-anim="/scenes/anim.json">
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
<!-- Scrollable track — one act per section of the page -->
|
|
214
|
+
<div class="hs-track">
|
|
215
|
+
|
|
216
|
+
<div class="hs-act"
|
|
217
|
+
data-from="intro"
|
|
218
|
+
data-to="desk_reveal"
|
|
219
|
+
style="height: 300vh">
|
|
220
|
+
<div class="hs-caption" data-at="0.15">Here is the desk</div>
|
|
221
|
+
</div>
|
|
222
|
+
|
|
223
|
+
<div class="hs-hold"
|
|
224
|
+
data-frame="desk_reveal"
|
|
225
|
+
style="height: 120vh">
|
|
226
|
+
<div class="hs-caption">Notice the details</div>
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div class="hs-act"
|
|
230
|
+
data-from="pingpong-start"
|
|
231
|
+
data-to="pingpong-end"
|
|
232
|
+
style="height: 200vh">
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div class="hs-act"
|
|
236
|
+
data-from="freecamera-start"
|
|
237
|
+
data-to="freecamera-end"
|
|
238
|
+
style="height: 150vh">
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<script src="holosplat.iife.js"></script>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`data-from`, `data-to`, and `data-frame` accept either a **Blender marker name** (from the exported JSON) or a raw **frame number**.
|
|
248
|
+
|
|
249
|
+
### Act types
|
|
250
|
+
|
|
251
|
+
There are four act types, selected by the element class and marker names:
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
#### `hs-act` — Scroll-driven playback
|
|
256
|
+
|
|
257
|
+
```html
|
|
258
|
+
<div class="hs-act"
|
|
259
|
+
data-from="intro"
|
|
260
|
+
data-to="desk_reveal"
|
|
261
|
+
style="height: 300vh">
|
|
262
|
+
</div>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Plays the animation linearly from `data-from` to `data-to` as the user scrolls through the act's height. The camera follows the baked path exactly.
|
|
266
|
+
|
|
267
|
+
- `data-from` → start frame / marker name
|
|
268
|
+
- `data-to` → end frame / marker name
|
|
269
|
+
- `data-loop="3"` → repeat the range N times within the scroll distance (omit or `"1"` for no repeat)
|
|
270
|
+
- If `data-from` > `data-to` the range plays in reverse.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
#### `hs-hold` — Freeze at a frame
|
|
275
|
+
|
|
276
|
+
```html
|
|
277
|
+
<div class="hs-hold"
|
|
278
|
+
data-frame="desk_reveal"
|
|
279
|
+
style="height: 120vh">
|
|
280
|
+
</div>
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Holds the animation frozen at a single frame while the visitor reads. The camera does not move. Use this after an `hs-act` to give reading time at a key moment.
|
|
284
|
+
|
|
285
|
+
- `data-frame` → frame number or marker name to hold at
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
#### `hs-act` with pingpong markers — Autonomous loop
|
|
290
|
+
|
|
291
|
+
```html
|
|
292
|
+
<div class="hs-act"
|
|
293
|
+
data-from="pingpong-start"
|
|
294
|
+
data-to="pingpong-end"
|
|
295
|
+
style="height: 200vh">
|
|
296
|
+
</div>
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
When both markers are named `*-start` / `*-end` (or any pair you configure in the editor as type **pingpong**), the act plays the frame range back and forth autonomously while the visitor is scrolled into it. The loop speed is independent of scroll position — the visitor lingers here and the scene plays by itself.
|
|
300
|
+
|
|
301
|
+
Transition in/out is smooth: the animation completes its current direction before handing off to the next act.
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
#### `hs-act` with freecamera markers — User orbit
|
|
306
|
+
|
|
307
|
+
```html
|
|
308
|
+
<div class="hs-act"
|
|
309
|
+
data-from="freecamera-start"
|
|
310
|
+
data-to="freecamera-end"
|
|
311
|
+
style="height: 150vh">
|
|
312
|
+
</div>
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Within this act's scroll range, the animation camera is released and the user can orbit, pan, and zoom freely (mouse drag / touch). The frame range still determines the transition frames used to enter and exit the act smoothly, but the camera is not driven by them while the user is inside.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
### Captions
|
|
320
|
+
|
|
321
|
+
Inside any act or hold, add `.hs-caption` elements. They fade in when the scroll progress through that act reaches `data-at` (0–1, default 0).
|
|
322
|
+
|
|
323
|
+
```html
|
|
324
|
+
<div class="hs-act" data-from="intro" data-to="reveal" style="height:300vh">
|
|
325
|
+
<div class="hs-caption" data-at="0.1">Opening line</div>
|
|
326
|
+
<div class="hs-caption" data-at="0.6">Second beat</div>
|
|
327
|
+
</div>
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Style `.hs-caption` and `.hs-caption--hidden` (added when not yet visible) yourself. Example:
|
|
331
|
+
|
|
332
|
+
```css
|
|
333
|
+
.hs-caption {
|
|
334
|
+
position: absolute;
|
|
335
|
+
bottom: 10vh;
|
|
336
|
+
left: 50%;
|
|
337
|
+
transform: translateX(-50%);
|
|
338
|
+
color: white;
|
|
339
|
+
font-size: 1.4rem;
|
|
340
|
+
transition: opacity 0.4s;
|
|
341
|
+
}
|
|
342
|
+
.hs-caption--hidden { opacity: 0; pointer-events: none; }
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## Callouts
|
|
348
|
+
|
|
349
|
+
Callouts are annotated world-space points anchored to 3D positions in the scene. The player projects each point to screen coordinates every frame, draws a dot and a connecting line to a card element, and hides the card when the point goes off-screen.
|
|
350
|
+
|
|
351
|
+
### Web page side
|
|
352
|
+
|
|
353
|
+
Add `.hs-callout` elements **inside the player container** (the element you passed to `player()`). Give each one a `data-id` matching the callout id exported from Blender.
|
|
354
|
+
|
|
355
|
+
```html
|
|
356
|
+
<div id="viewer" style="width:100%; height:600px">
|
|
357
|
+
|
|
358
|
+
<div class="hs-callout" data-id="keyboard"
|
|
359
|
+
data-offset-x="90" data-offset-y="-35">
|
|
360
|
+
<h3>Mechanical keyboard</h3>
|
|
361
|
+
<p>Cherry MX Browns, 65% layout</p>
|
|
362
|
+
</div>
|
|
363
|
+
|
|
364
|
+
<div class="hs-callout" data-id="screen"
|
|
365
|
+
data-offset-x="-120" data-offset-y="20">
|
|
366
|
+
<h3>Monitor</h3>
|
|
367
|
+
<p>4K, 144 Hz</p>
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<script src="holosplat.iife.js"></script>
|
|
373
|
+
<script>
|
|
374
|
+
HoloSplat.player('#viewer', {
|
|
375
|
+
src: '/scenes/desk.spz',
|
|
376
|
+
animation: '/scenes/desk.json',
|
|
377
|
+
});
|
|
378
|
+
</script>
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
| Attribute | Description |
|
|
382
|
+
|-----------|-------------|
|
|
383
|
+
| `data-id` | Must match the callout id from Blender (the part after `hs.`) |
|
|
384
|
+
| `data-offset-x` | Horizontal offset in px from the dot to the card anchor point |
|
|
385
|
+
| `data-offset-y` | Vertical offset in px |
|
|
386
|
+
|
|
387
|
+
The player adds `.hs-callout--hidden` when the 3D point is behind the camera or off-screen.
|
|
388
|
+
|
|
389
|
+
### Styling callouts
|
|
390
|
+
|
|
391
|
+
The player injects minimal structural CSS. You provide the visual design:
|
|
392
|
+
|
|
393
|
+
```css
|
|
394
|
+
/* Card */
|
|
395
|
+
.hs-callout {
|
|
396
|
+
position: absolute; /* required — player positions it */
|
|
397
|
+
background: rgba(0,0,0,0.7);
|
|
398
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
399
|
+
border-radius: 8px;
|
|
400
|
+
padding: 12px 16px;
|
|
401
|
+
color: #fff;
|
|
402
|
+
font-size: 0.85rem;
|
|
403
|
+
pointer-events: auto;
|
|
404
|
+
transition: opacity 0.2s;
|
|
405
|
+
}
|
|
406
|
+
.hs-callout--hidden { opacity: 0; pointer-events: none; }
|
|
407
|
+
|
|
408
|
+
/* Dot and line (SVG drawn by the player) */
|
|
409
|
+
.hs-lines circle { fill: #fff; }
|
|
410
|
+
.hs-lines line { stroke: rgba(255,255,255,0.5); stroke-width: 1px; }
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Access a callout element programmatically with `p.callout('keyboard')`.
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## Blender workflow
|
|
418
|
+
|
|
419
|
+
### Export script
|
|
420
|
+
|
|
421
|
+
The script lives at `blender/export_holosplat.py`. It exports:
|
|
422
|
+
- The active camera's position and forward direction, one entry per frame
|
|
423
|
+
- The camera's field of view (always vertical/fovY)
|
|
424
|
+
- Near and far clip distances
|
|
425
|
+
- All timeline markers within the export range
|
|
426
|
+
- All callout anchor positions
|
|
427
|
+
|
|
428
|
+
**Steps:**
|
|
429
|
+
|
|
430
|
+
1. Open Blender and load your `.blend` file.
|
|
431
|
+
2. Go to the **Scripting** workspace.
|
|
432
|
+
3. Click **Open** and select `export_holosplat.py` (or paste it into a new text block).
|
|
433
|
+
4. Edit the `CONFIG` section at the top of the script as needed (see below).
|
|
434
|
+
5. Click **Run Script** (or press Alt+P with the cursor in the text editor).
|
|
435
|
+
|
|
436
|
+
The JSON file is saved next to your `.blend` file by default. Copy or symlink it into your project's `scenes/` folder.
|
|
437
|
+
|
|
438
|
+
### CONFIG options
|
|
439
|
+
|
|
440
|
+
| Variable | Default | Description |
|
|
441
|
+
|----------|---------|-------------|
|
|
442
|
+
| `OUTPUT_PATH` | `"//"` | Output folder. `"//"` = same folder as the `.blend` file |
|
|
443
|
+
| `OUTPUT_NAME` | `None` | Output filename without `.json`. `None` = use the `.blend` filename |
|
|
444
|
+
| `CAMERA_NAME` | `None` | Name of the camera object. `None` = use the scene's active camera |
|
|
445
|
+
| `FRAME_START` | `0` | First frame to export. Defaults to `0` (not Blender's scene start) so that markers at frame 0 are included |
|
|
446
|
+
| `FRAME_END` | `None` | Last frame. `None` = use `scene.frame_end` |
|
|
447
|
+
| `GS_OBJECT_NAME` | `None` | Name of the imported Gaussian Splat mesh object. See [below](#coordinate-systems-and-gs-object) |
|
|
448
|
+
| `FLIP_Y` | `False` | Set `True` if loading the splat with `flipY: true` in the player |
|
|
449
|
+
|
|
450
|
+
### Timeline markers
|
|
451
|
+
|
|
452
|
+
Markers become the named reference points you use in `data-from`, `data-to`, and `data-frame` attributes on your scroll acts, and also control runtime camera behaviour when they carry an `hs-` prefix.
|
|
453
|
+
|
|
454
|
+
**How to add a marker in Blender:**
|
|
455
|
+
|
|
456
|
+
1. In Blender's **Timeline** or **Dopesheet**, scrub to the frame you want to mark.
|
|
457
|
+
2. Press **M** to place a marker.
|
|
458
|
+
3. With the marker selected, press **F2** (or double-click) to rename it.
|
|
459
|
+
|
|
460
|
+
After export the markers appear in the JSON:
|
|
461
|
+
|
|
462
|
+
```json
|
|
463
|
+
{
|
|
464
|
+
"markers": {
|
|
465
|
+
"intro": 0,
|
|
466
|
+
"desk_reveal": 72,
|
|
467
|
+
"pingpong-start": 90,
|
|
468
|
+
"pingpong-end": 150,
|
|
469
|
+
"hs-free": 155,
|
|
470
|
+
"hs-locked": 220,
|
|
471
|
+
"outro": 240
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Frame numbers are **0-based relative to `FRAME_START`**.
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
#### Scroll-act markers (any name you choose)
|
|
481
|
+
|
|
482
|
+
These are plain markers with no special naming rules. You reference them in `data-from`, `data-to`, `data-frame`, and in `hs-config.json`.
|
|
483
|
+
|
|
484
|
+
| Typical name | Convention |
|
|
485
|
+
|---|---|
|
|
486
|
+
| `intro` | First frame of the opening move |
|
|
487
|
+
| `desk_reveal` | Key moment — use as a hold target |
|
|
488
|
+
| `pingpong-start` / `pingpong-end` | Loop range for a pingpong act |
|
|
489
|
+
| `freecam-start` / `freecam-end` | Entry/exit frames for a freecamera act |
|
|
490
|
+
| `outro` | Final camera position |
|
|
491
|
+
|
|
492
|
+
Any name works — the names above are only a convention. Each scroll act needs a `from` + `to` pair (or a single `frame` for a hold).
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
#### `hs-*` camera control markers
|
|
497
|
+
|
|
498
|
+
Markers whose names begin with `hs-` are intercepted by the player at runtime and switch the camera between animation-driven and user-controlled modes. The **most recently passed** `hs-*` marker determines the current mode; earlier ones are superseded.
|
|
499
|
+
|
|
500
|
+
These markers have no effect when `scrollScene()` is driving the camera — they apply only during self-playing (non-scroll) animation.
|
|
501
|
+
|
|
502
|
+
| Marker | Effect |
|
|
503
|
+
|--------|--------|
|
|
504
|
+
| `hs-free` | Releases the camera to full free orbit. The user can orbit, zoom, and (unless a `focal-point` empty exists) pan. Camera snaps to the animation eye/target at the moment of transition. |
|
|
505
|
+
| `hs-locked` | Returns the camera to animation-driven mode. Camera snaps back to the baked path. |
|
|
506
|
+
| `hs-h{deg}` | Free orbit restricted to ±*deg* ° horizontally from the entry angle. Example: `hs-h45` allows 90 ° of horizontal sweep. |
|
|
507
|
+
| `hs-v{deg}` | Free orbit restricted to ±*deg* ° vertically from the entry angle. Example: `hs-v20` prevents the user from looking too far up or down. |
|
|
508
|
+
| `hs-h{deg}-v{deg}` | Both restrictions combined. Example: `hs-h30-v15` gives a tight look-around window. |
|
|
509
|
+
|
|
510
|
+
**Focal-point orbit anchor** — place a Blender Empty named `focal-point` (or `hs-focal-point`) anywhere in your scene. When free-camera mode is active, the orbit target locks to that world position instead of the animation look-at point, and panning is disabled. This keeps the object centred in frame while the user spins around it.
|
|
511
|
+
|
|
512
|
+
**Typical placement:**
|
|
513
|
+
|
|
514
|
+
```
|
|
515
|
+
frame 0 ──── animation plays, camera locked to baked path
|
|
516
|
+
:
|
|
517
|
+
frame 155 ── hs-free → user can orbit freely around focal-point
|
|
518
|
+
:
|
|
519
|
+
frame 220 ── hs-locked → camera returns to baked path
|
|
520
|
+
:
|
|
521
|
+
frame 240 ── outro
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
### Adding part Empties
|
|
525
|
+
|
|
526
|
+
See [Animated multi-part scenes → Blender setup](#blender-setup) for the full workflow. Quick summary: put each part's Empty in the **`HoloSplat Parts`** collection (or prefix names with `hs-part.`), animate them, then run the export script.
|
|
527
|
+
|
|
528
|
+
### Adding callout anchors
|
|
529
|
+
|
|
530
|
+
A callout anchor is a world-space point in Blender that the player will project to screen coordinates on every frame. Create one for each annotation you want.
|
|
531
|
+
|
|
532
|
+
**Method 1 — name prefix `hs.`** (recommended for a few callouts):
|
|
533
|
+
|
|
534
|
+
1. In the viewport, press **Shift+A → Empty → Plain Axes**.
|
|
535
|
+
2. Move it to the exact point you want to annotate (e.g. the corner of a keyboard).
|
|
536
|
+
3. Rename it to `hs.keyboard` — the part after `hs.` becomes the callout `id`.
|
|
537
|
+
|
|
538
|
+
**Method 2 — collection** (for many callouts):
|
|
539
|
+
|
|
540
|
+
1. Create a collection named exactly **`HoloSplat Callouts`**.
|
|
541
|
+
2. Add your Empty objects to it. The object name becomes the `id` directly (no prefix needed).
|
|
542
|
+
|
|
543
|
+
The exported positions are in HoloSplat's coordinate space and match the splat file exactly (assuming `GS_OBJECT_NAME` is set correctly — see below).
|
|
544
|
+
|
|
545
|
+
### Coordinate systems and GS object
|
|
546
|
+
|
|
547
|
+
Blender uses a **Z-up** coordinate system (X right, Y forward, Z up).
|
|
548
|
+
HoloSplat uses **Y-up** (X right, Y up, Z back).
|
|
549
|
+
|
|
550
|
+
If you imported the Gaussian Splat using the **3D Gaussian Splatting** addon (or any addon that applies a rotation/scale transform to the object), the imported mesh object has a world-space transform baked in. To get camera and callout positions that align with the actual vertices in the `.spz`/`.ply` file, set `GS_OBJECT_NAME` to the name of that mesh object. The script will then work in the object's local space, which is the same coordinate space the splat file uses.
|
|
551
|
+
|
|
552
|
+
```python
|
|
553
|
+
GS_OBJECT_NAME = "desk_2" # name of the imported GS mesh in your scene
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
If your scene has no per-object transform on the GS mesh (or you placed the camera manually), leave `GS_OBJECT_NAME = None`. The script will apply the default Blender → HoloSplat axis conversion: `hs_x = bl_x`, `hs_y = bl_z`, `hs_z = -bl_y`.
|
|
557
|
+
|
|
558
|
+
If you load the splat with `flipY: true` in the player, also set `FLIP_Y = True` in the script so the camera and callout coordinates are rotated to match.
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## Art-direction editor
|
|
563
|
+
|
|
564
|
+
The editor is a local web app served at `/holosplat/`. It is **never deployed** — it is excluded from all production builds.
|
|
565
|
+
|
|
566
|
+
### Starting the editor
|
|
567
|
+
|
|
568
|
+
```bash
|
|
569
|
+
python server.py
|
|
570
|
+
# then open: http://localhost:8080/holosplat/
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
The editor needs the `/hs-api` routes to read and write files. `server.py` provides them. If you're using a Node.js server instead, see [Node.js server middleware](#nodejs-server-middleware).
|
|
574
|
+
|
|
575
|
+
### Editor workflow
|
|
576
|
+
|
|
577
|
+
1. **Load files** — Enter paths to your scene file and animation JSON in the Files panel (relative to the project root, e.g. `scenes/desk.spz`). Click the reload arrows.
|
|
578
|
+
|
|
579
|
+
2. **Preview** — The scene renders in the right panel. Use the scrubber at the bottom to seek through frames. The marker list on the left shows all markers exported from Blender.
|
|
580
|
+
|
|
581
|
+
3. **Build the timeline** — Use the `+ Act`, `+ Hold`, `+ Pingpong`, `+ Freecam` buttons to add acts.
|
|
582
|
+
|
|
583
|
+
4. **Assign markers** — Each act row has dropdowns for the from/to/frame markers. Select the Blender markers you want each act to span.
|
|
584
|
+
|
|
585
|
+
5. **Set heights** — Drag the height field (the number at the right of each act row) to set how many viewport heights of scroll that act takes.
|
|
586
|
+
|
|
587
|
+
6. **Save** — Click **Save** to write `hs-config.json` back to the server. The dot indicator in the title turns on when there are unsaved changes.
|
|
588
|
+
|
|
589
|
+
When the API server is offline (no `server.py` running), the Save button becomes **Export** and downloads a `hs-config.json` file instead.
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## hs-config.json reference
|
|
594
|
+
|
|
595
|
+
```json
|
|
596
|
+
{
|
|
597
|
+
"version": 1,
|
|
598
|
+
"scene": "scenes/scene.spz",
|
|
599
|
+
"animation": "scenes/anim.json",
|
|
600
|
+
"acts": [
|
|
601
|
+
{
|
|
602
|
+
"id": "intro",
|
|
603
|
+
"type": "act",
|
|
604
|
+
"from": "intro",
|
|
605
|
+
"to": "desk_reveal",
|
|
606
|
+
"height": 300
|
|
607
|
+
},
|
|
608
|
+
{
|
|
609
|
+
"id": "hold1",
|
|
610
|
+
"type": "hold",
|
|
611
|
+
"frame": "desk_reveal",
|
|
612
|
+
"height": 120
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
"id": "loop",
|
|
616
|
+
"type": "pingpong",
|
|
617
|
+
"from": "pingpong-start",
|
|
618
|
+
"to": "pingpong-end",
|
|
619
|
+
"height": 200
|
|
620
|
+
},
|
|
621
|
+
{
|
|
622
|
+
"id": "explore",
|
|
623
|
+
"type": "freecamera",
|
|
624
|
+
"from": "freecam-start",
|
|
625
|
+
"to": "freecam-end",
|
|
626
|
+
"height": 150
|
|
627
|
+
}
|
|
628
|
+
]
|
|
629
|
+
}
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
| Field | Description |
|
|
633
|
+
|-------|-------------|
|
|
634
|
+
| `type` | `"act"` \| `"hold"` \| `"pingpong"` \| `"freecamera"` |
|
|
635
|
+
| `from` / `to` | Blender marker name or frame number (used by `act`, `pingpong`, `freecamera`) |
|
|
636
|
+
| `frame` | Blender marker name or frame number (used by `hold`) |
|
|
637
|
+
| `height` | Scroll distance in `vh` units |
|
|
638
|
+
|
|
639
|
+
---
|
|
640
|
+
|
|
641
|
+
## Node.js server middleware
|
|
642
|
+
|
|
643
|
+
Install the package, then mount the middleware in development only.
|
|
644
|
+
|
|
645
|
+
```js
|
|
646
|
+
import { createHsApiHandler } from 'holosplat/server';
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
**Express:**
|
|
650
|
+
```js
|
|
651
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
652
|
+
app.use('/hs-api', createHsApiHandler());
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Vite (`vite.config.js`):**
|
|
657
|
+
```js
|
|
658
|
+
import { createHsApiHandler } from 'holosplat/server';
|
|
659
|
+
|
|
660
|
+
export default {
|
|
661
|
+
server: {
|
|
662
|
+
middlewares: [createHsApiHandler()] // mounts at /hs-api
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
**Next.js (`pages/api/hs-api/[...route].js`):**
|
|
668
|
+
```js
|
|
669
|
+
import { createHsApiHandler } from 'holosplat/server';
|
|
670
|
+
const handler = createHsApiHandler();
|
|
671
|
+
|
|
672
|
+
export default function hsApi(req, res) {
|
|
673
|
+
req.url = '/' + (req.query.route ?? []).join('/')
|
|
674
|
+
+ (req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '');
|
|
675
|
+
handler(req, res);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export const config = { api: { bodyParser: false } };
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
`createHsApiHandler(root?)` accepts an optional root directory (defaults to `process.cwd()`). Path traversal outside the root is rejected.
|
|
682
|
+
|
|
683
|
+
---
|
|
684
|
+
|
|
685
|
+
## Animated multi-part scenes
|
|
686
|
+
|
|
687
|
+
A multi-part scene loads several Gaussian Splat files as independent rigid bodies, each driven by an animated Empty in Blender. This lets you animate articulated objects — a folding headphone, a robot arm, a door opening — while keeping full splat quality on every part.
|
|
688
|
+
|
|
689
|
+
### How it works
|
|
690
|
+
|
|
691
|
+
1. Capture each part of the object as a separate `.spz`/`.ply` file (headband, hinge, cup…).
|
|
692
|
+
2. In Blender, import each part and parent it to an Empty with a matching name.
|
|
693
|
+
3. Animate the Empties on the Blender timeline.
|
|
694
|
+
4. Run the export script — it records each Empty's position + rotation quaternion per frame.
|
|
695
|
+
5. At runtime, HoloSplat merges all splat files into one GPU buffer, tags each splat with its part index, and applies the per-frame transforms on the GPU. Depth sorting works across all parts automatically.
|
|
696
|
+
|
|
697
|
+
### Blender setup
|
|
698
|
+
|
|
699
|
+
1. Import each splat part into your scene. Set `GS_OBJECT_NAME` in the export script to the root object (or the shared parent) so coordinate spaces align.
|
|
700
|
+
|
|
701
|
+
2. Create one Empty per part (**Add → Empty → Plain Axes**). Name each Empty to match what you'll use in `loadParts()`.
|
|
702
|
+
|
|
703
|
+
3. Parent each splat mesh to its Empty: select the mesh, Shift-click the Empty, press **Ctrl+P → Object**.
|
|
704
|
+
|
|
705
|
+
4. Put all part Empties into a collection named **`HoloSplat Parts`** (or prefix names with `hs-part.`):
|
|
706
|
+
```
|
|
707
|
+
Collection: HoloSplat Parts
|
|
708
|
+
├── headband → id "headband"
|
|
709
|
+
├── hinge_left → id "hinge_left"
|
|
710
|
+
├── hinge_right → id "hinge_right"
|
|
711
|
+
└── cup_left → id "cup_left"
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
5. Animate the Empties on the timeline. Add **timeline markers** (M key) for scroll acts as usual.
|
|
715
|
+
|
|
716
|
+
6. Run `export_holosplat.py`. The output JSON will include an `objects` array alongside the camera data.
|
|
717
|
+
|
|
718
|
+
> **Apply scale** (Ctrl+A → Scale) on all Empties before exporting. Uneven scale on an Empty is not exported and will cause misalignment at runtime.
|
|
719
|
+
|
|
720
|
+
### JavaScript
|
|
721
|
+
|
|
722
|
+
Pass a `parts` map instead of (or in addition to) `src`:
|
|
723
|
+
|
|
724
|
+
```js
|
|
725
|
+
// Script tag
|
|
726
|
+
HoloSplat.player('#viewer', {
|
|
727
|
+
parts: {
|
|
728
|
+
headband: '/scenes/headband.spz',
|
|
729
|
+
hinge_left: '/scenes/hinge_left.spz',
|
|
730
|
+
hinge_right: '/scenes/hinge_right.spz',
|
|
731
|
+
cup_left: '/scenes/cup_left.spz',
|
|
732
|
+
cup_right: '/scenes/cup_right.spz',
|
|
733
|
+
},
|
|
734
|
+
animation: '/scenes/headphones.json',
|
|
735
|
+
});
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
```js
|
|
739
|
+
// ESM
|
|
740
|
+
import { player } from 'holosplat';
|
|
741
|
+
|
|
742
|
+
const p = player('#viewer', {
|
|
743
|
+
parts: {
|
|
744
|
+
headband: '/scenes/headband.spz',
|
|
745
|
+
hinge_left: '/scenes/hinge_left.spz',
|
|
746
|
+
},
|
|
747
|
+
animation: '/scenes/headphones.json',
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// Swap parts at runtime
|
|
751
|
+
p.loadParts({ headband: '/scenes/headband_v2.spz', ... });
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
The keys in `parts` must match the Empty names (or `hs-part.` suffixes) used during export.
|
|
755
|
+
|
|
756
|
+
### Animation JSON format (v2)
|
|
757
|
+
|
|
758
|
+
The export script writes version `2` when parts are present. The `objects` array is optional — v1 files (no `objects`) load normally as single-part scenes.
|
|
759
|
+
|
|
760
|
+
```json
|
|
761
|
+
{
|
|
762
|
+
"version": 2,
|
|
763
|
+
"fps": 24,
|
|
764
|
+
"frameCount": 120,
|
|
765
|
+
"fov": 45.0,
|
|
766
|
+
"frames": [ ... ],
|
|
767
|
+
"objects": [
|
|
768
|
+
{
|
|
769
|
+
"id": "headband",
|
|
770
|
+
"frames": [
|
|
771
|
+
0.0, 1.2, 0.0, 0.0, 0.0, 0.0, 1.0,
|
|
772
|
+
0.0, 1.2, 0.0, 0.0, 0.0, 0.0, 1.0,
|
|
773
|
+
...
|
|
774
|
+
]
|
|
775
|
+
},
|
|
776
|
+
{
|
|
777
|
+
"id": "hinge_left",
|
|
778
|
+
"frames": [ ... ]
|
|
779
|
+
}
|
|
780
|
+
],
|
|
781
|
+
"markers": { "open": 0, "closed": 60 }
|
|
782
|
+
}
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
Each entry in `objects.frames` is 7 floats per frame:
|
|
786
|
+
```
|
|
787
|
+
px py pz — position in splat-file coordinate space
|
|
788
|
+
qx qy qz qw — rotation quaternion (XYZW)
|
|
789
|
+
```
|
|
790
|
+
|
|
791
|
+
---
|
|
792
|
+
|
|
793
|
+
## Building the library
|
|
794
|
+
|
|
795
|
+
```bash
|
|
796
|
+
node build.js # builds dist/holosplat.esm.js and dist/holosplat.iife.js
|
|
797
|
+
node build.js --watch # rebuild on every change
|
|
798
|
+
```
|
|
799
|
+
|
|
800
|
+
Source is in `src/`. Entry point is `src/index.js`. Bundle target is browser (esbuild).
|
|
801
|
+
`src/server.js` is Node.js-only and is **not bundled** — it is exported as the `holosplat/server` subpath.
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
## JavaScript API reference
|
|
806
|
+
|
|
807
|
+
### `create(options)` — low-level viewer
|
|
808
|
+
|
|
809
|
+
```js
|
|
810
|
+
import { create } from 'holosplat';
|
|
811
|
+
|
|
812
|
+
const viewer = await create({
|
|
813
|
+
canvas: '#myCanvas', // CSS selector or HTMLCanvasElement
|
|
814
|
+
src: '/scenes/scene.spz',
|
|
815
|
+
background: '#111111',
|
|
816
|
+
splatScale: 1.0,
|
|
817
|
+
onLoad: () => console.log('ready'),
|
|
818
|
+
onProgress: p => console.log(p * 100 + '%'),
|
|
819
|
+
onError: err => console.error(err),
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
viewer.setBackground('#222');
|
|
823
|
+
viewer.setSplatScale(1.5);
|
|
824
|
+
viewer.resetCamera();
|
|
825
|
+
viewer.focusCamera(); // same as resetCamera but preserves camera angle
|
|
826
|
+
viewer.destroy();
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
### `Viewer` class
|
|
830
|
+
|
|
831
|
+
```js
|
|
832
|
+
import { Viewer } from 'holosplat';
|
|
833
|
+
|
|
834
|
+
const viewer = new Viewer({ canvas: '#c', background: '#000' });
|
|
835
|
+
await viewer.init();
|
|
836
|
+
await viewer.load('/scenes/scene.spz');
|
|
837
|
+
viewer.start();
|
|
838
|
+
|
|
839
|
+
// Animation
|
|
840
|
+
await viewer.loadAnimationUrl('/scenes/anim.json');
|
|
841
|
+
viewer.setAnimationPaused(true);
|
|
842
|
+
viewer.setCameraFree(true);
|
|
843
|
+
|
|
844
|
+
// Frame callback — called every render tick
|
|
845
|
+
viewer.onFrame = (viewMatrix, projMatrix, width, height) => { … };
|
|
846
|
+
|
|
847
|
+
// Project world-space points to screen
|
|
848
|
+
const hits = viewer.projectCallouts([{ id: 'dot', pos: [1, 2, 3] }]);
|
|
849
|
+
// → [{ id: 'dot', visible: true, x: 540, y: 320 }]
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
### `Animation` class
|
|
853
|
+
|
|
854
|
+
```js
|
|
855
|
+
import { Animation, loadAnimation } from 'holosplat';
|
|
856
|
+
|
|
857
|
+
const anim = await loadAnimation('/scenes/anim.json');
|
|
858
|
+
|
|
859
|
+
anim.fps // number
|
|
860
|
+
anim.frameCount // number
|
|
861
|
+
anim.markers // { name: frameNumber, … }
|
|
862
|
+
anim.callouts // [{ id, pos: [x,y,z] }]
|
|
863
|
+
|
|
864
|
+
anim.seekFrame(42);
|
|
865
|
+
anim.tick(deltaSeconds); // advance playback
|
|
866
|
+
anim.getCameraFrame(); // → { eye: [x,y,z], target: [x,y,z] }
|
|
867
|
+
```
|
|
868
|
+
|
|
869
|
+
### `scrollScene(sceneEl, playerInstance, opts?)`
|
|
870
|
+
|
|
871
|
+
```js
|
|
872
|
+
import { player, scrollScene } from 'holosplat';
|
|
873
|
+
|
|
874
|
+
const p = player('.hs-stage', { src: '…', animation: '…' });
|
|
875
|
+
const sc = scrollScene(document.querySelector('.hs-scene'), p);
|
|
876
|
+
|
|
877
|
+
sc.rebuild(); // re-read DOM after programmatic changes
|
|
878
|
+
sc.destroy(); // remove scroll listeners
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
### `compressToSpz(data, count, opts?)` → `Promise<ArrayBuffer>`
|
|
882
|
+
|
|
883
|
+
Converts canonical Gaussian data (Float32Array, 16 floats/splat) to a gzip-compressed `.spz` file.
|
|
884
|
+
|
|
885
|
+
```js
|
|
886
|
+
import { compressToSpz } from 'holosplat';
|
|
887
|
+
|
|
888
|
+
const buffer = await compressToSpz(gaussianData, numSplats);
|
|
889
|
+
const blob = new Blob([buffer], { type: 'application/octet-stream' });
|
|
890
|
+
```
|