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 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
+ ```