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.
@@ -0,0 +1,2947 @@
1
+ /**
2
+ * HoloSplat overlay editor.
3
+ * Injected by player.js when ?hs is in the URL. Connects to window.__hsPlayers
4
+ * to access the live player instance without creating a second viewer.
5
+ *
6
+ * All config lives in the player() JS call in the source file.
7
+ * Save writes back via /hs-api/js-* endpoints — no data-hs-* DOM attrs.
8
+ */
9
+ (function () {
10
+ if (window.__hsEditor) return;
11
+ window.__hsEditor = true;
12
+
13
+ // ── State ───────────────────────────────────────────────────────────────────
14
+ const S = {
15
+ entry: null, // { root, api, viewer }
16
+ markers: {},
17
+ frameCount: 0,
18
+ panelOpen: true,
19
+ apiOnline: false,
20
+ diskFiles: null, // Set<string> of every spz/ply/splat path under scenes/ (relative to project root) — from /hs-api/ls. Populated on connect and re-fetched only on explicit splats-dir change/reload. Never probed per-file.
21
+ animEverPlayed: false,
22
+ scrubbing: false,
23
+ partsKey: '', // sorted part-ID string; re-renders badges whenever loaded set changes
24
+ sceneConfigs: {}, // markerName → config object
25
+ maskConfigs: {}, // mask volume name → { feather }
26
+ sceneCards: {}, // markerName → { playBtn, barEl, from, to, frames } for rAF updates
27
+ sceneLbl: null, // overlay label injected into player root
28
+ activeMarker: null, // currently active marker name
29
+ showFocalMarker: false, // debug toggle: draw a marker at the focal-point's screen position
30
+ focalMarkerEl: null, // overlay marker injected into player root
31
+ focalMarkerCb: null, // the "Visualize" toggle's checkbox, set in init()
32
+ globalSh: 0, // SH degree — global, not per-scene
33
+ globalZIndex: 5, // player z-index
34
+ globalAaDilation: 0.15, // anti-aliasing covariance dilation
35
+ // Asset clip files (see export_holosplat_clips.py) — each entry:
36
+ // { url, name, status: 'idle'|'loading'|'ok'|'error', clipIds }.
37
+ // Persisted as just the url list (clips: [...] // hs-clips); name/status/
38
+ // clipIds are derived at load time, not saved.
39
+ assets: [],
40
+ };
41
+ let _apiReady; // Promise set in init(); awaited before loadPageState so HTML read uses correct apiOnline
42
+ let _saveTimer; // debounce handle for saveScenesAttr
43
+ let _maskSaveTimer; // debounce handle for saveMasksAttr
44
+ let _aaSaveTimer; // debounce handle for saveGlobalAaDilation
45
+ let _assetsSaveTimer; // debounce handle for saveAssetsAttr
46
+
47
+ // ── CSS ─────────────────────────────────────────────────────────────────────
48
+ const CSS = `
49
+ #__hs-ed {
50
+ position:fixed;top:20px;right:20px;
51
+ bottom:calc(var(--hs-tl-h, 160px) + 40px);
52
+ z-index:99999;display:flex;pointer-events:none;transition:bottom .15s;
53
+ }
54
+ #__hs-ed * { box-sizing:border-box;margin:0; }
55
+ #__hs-tab {
56
+ pointer-events:auto;align-self:flex-start;margin-top:48px;
57
+ writing-mode:vertical-rl;transform:rotate(180deg);
58
+ background:#1a1a1a;border:1px solid #333;border-right:none;
59
+ color:#999;font-size:0.875rem;font-weight:700;letter-spacing:0.1em;font-family:system-ui,sans-serif;
60
+ padding:12px 7px;cursor:pointer;border-radius:3px 0 0 3px;user-select:none;
61
+ }
62
+ #__hs-tab:hover { color:#ccc; }
63
+ #__hs-panel {
64
+ pointer-events:auto;width:420px;height:100%;
65
+ background:#1a1a1a;border:1px solid #2e2e2e;border-radius:20px;
66
+ box-shadow:0 8px 32px rgba(0,0,0,.5);
67
+ display:flex;flex-direction:column;overflow:hidden;
68
+ font-family:system-ui,sans-serif;font-size:1rem;color:#eee;
69
+ }
70
+ #__hs-body {
71
+ flex:1;overflow-y:auto;overflow-x:hidden;
72
+ scrollbar-width:thin;scrollbar-color:#3a7aff #1a1a1a;
73
+ }
74
+ #__hs-body::-webkit-scrollbar { width:8px; }
75
+ #__hs-body::-webkit-scrollbar-track { background:#1a1a1a; }
76
+ #__hs-body::-webkit-scrollbar-thumb { background:#3a7aff;border-radius:4px; }
77
+ #__hs-body::-webkit-scrollbar-thumb:hover { background:#5a9aff; }
78
+ #__hs-body::-webkit-scrollbar-button,
79
+ #__hs-body::-webkit-scrollbar-button:single-button,
80
+ #__hs-body::-webkit-scrollbar-button:vertical:start,
81
+ #__hs-body::-webkit-scrollbar-button:vertical:end,
82
+ #__hs-body::-webkit-scrollbar-button:vertical:start:increment,
83
+ #__hs-body::-webkit-scrollbar-button:vertical:end:increment,
84
+ #__hs-body::-webkit-scrollbar-button:vertical:start:decrement,
85
+ #__hs-body::-webkit-scrollbar-button:vertical:end:decrement {
86
+ display:none;width:0;height:0;
87
+ }
88
+ #__hs-panel.closed { display:none; }
89
+ #__hs-panel.minimized { height:auto; }
90
+ #__hs-panel.minimized #__hs-body { display:none; }
91
+
92
+ /* toolbar */
93
+ #__hs-tb {
94
+ display:flex;align-items:center;gap:8px;flex-shrink:0;
95
+ padding:10px 28px;background:#141414;border-bottom:1px solid #2e2e2e;
96
+ }
97
+ #__hs-tb h1 { font-size:1.1rem;font-weight:700;white-space:nowrap; }
98
+ #__hs-st {
99
+ flex:1;font-size:0.875rem;color:#aaa;
100
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
101
+ }
102
+ #__hs-st.err { color:#f87; }
103
+ #__hs-badge {
104
+ font-size:0.875rem;padding:3px 9px;border-radius:8px;flex-shrink:0;
105
+ background:#1e2e1e;border:1px solid #2a4a2a;color:#5a9a5a;white-space:nowrap;
106
+ }
107
+ #__hs-badge.off { background:#2e1e1e;border-color:#4a2a2a;color:#9a5a5a; }
108
+ .__hs-btn {
109
+ background:#242424;border:1px solid #333;color:#aaa;
110
+ padding:5px 11px;border-radius:3px;cursor:pointer;font-size:0.875rem;
111
+ white-space:nowrap;flex-shrink:0;font-family:inherit;
112
+ }
113
+ .__hs-btn:hover { background:#2e2e2e;color:#eee; }
114
+ .__hs-btn.pri { background:#1e3555;border-color:#2a5599;color:#aac; }
115
+ .__hs-btn.pri:hover { background:#254a80; }
116
+ .__hs-btn:disabled { opacity:0.35;cursor:default; }
117
+ .__hs-btn.cur { background:#252520;border-color:#4a4a30;color:#ddd; }
118
+ .__hs-btn.active { background:#1a3020;border-color:#2a6a40;color:#6aaa7a; }
119
+ .__hs-link { display:inline-flex;align-items:center;text-decoration:none; }
120
+
121
+ /* tabs */
122
+ #__hs-tabs {
123
+ display:flex;flex-shrink:0;border-bottom:1px solid #2e2e2e;background:#161616;
124
+ }
125
+ .__hs-tabbtn {
126
+ flex:1;background:none;border:none;color:#888;
127
+ padding:10px 0;font-size:0.8125rem;font-weight:700;text-transform:uppercase;
128
+ letter-spacing:0.07em;cursor:pointer;font-family:inherit;
129
+ border-bottom:2px solid transparent;transition:color .12s,border-color .12s;
130
+ }
131
+ .__hs-tabbtn:hover { color:#ccc; }
132
+ .__hs-tabbtn.active { color:#fff;border-bottom-color:#3a7aff; }
133
+ .__hs-tabpanel { display:none; }
134
+ .__hs-tabpanel.active { display:block; }
135
+
136
+ /* pane */
137
+ .__hs-pane { border-bottom:1px solid #222; }
138
+ .__hs-pt {
139
+ font-size:0.875rem;font-weight:700;text-transform:uppercase;
140
+ letter-spacing:0.07em;color:#999;padding:8px 28px 5px;
141
+ }
142
+
143
+ /* collapsible pane */
144
+ #__hs-ed .__hs-cpane { border-bottom:1px solid rgba(255,255,255,0.18); margin:0 10px 16px; }
145
+ .__hs-cpane-hd {
146
+ display:flex;align-items:center;gap:8px;padding:8px 28px;
147
+ cursor:pointer;user-select:none;
148
+ }
149
+ #__hs-ed .__hs-cpane-hd { margin-top:5px; }
150
+ .__hs-cpane-hd:hover .__hs-cpane-title { color:#fff; }
151
+ .__hs-cpane-tri { font-size:0.75rem;color:#666;flex-shrink:0; }
152
+ .__hs-cpane-title {
153
+ font-size:0.875rem;font-weight:700;text-transform:uppercase;
154
+ letter-spacing:0.07em;color:#ccc;
155
+ }
156
+ .__hs-cpane-body.closed { display:none; }
157
+ .__hs-sub-pt {
158
+ font-size:0.75rem;font-weight:600;text-transform:uppercase;
159
+ letter-spacing:0.06em;color:#999;padding:6px 28px 2px;
160
+ }
161
+ #__hs-ed .__hs-sub-pt { margin-top:5px; }
162
+
163
+ /* files */
164
+ .__hs-fr { display:flex;align-items:center;gap:8px;padding:5px 28px 7px; }
165
+ .__hs-fl { font-size:0.875rem;color:#aaa;width:36px;flex-shrink:0; }
166
+ .__hs-fr input {
167
+ flex:1;min-width:0;background:#1e1e1e;border:1px solid #2e2e2e;color:#ccc;
168
+ padding:5px 9px;border-radius:3px;font-size:0.875rem;font-family:inherit;
169
+ }
170
+ .__hs-fr input:focus { border-color:#3a7aff;outline:none; }
171
+ .__hs-fr input[readonly] { color:#555;cursor:default; }
172
+ .__hs-fr input::selection { background:#3a7aff;color:#fff; }
173
+
174
+ .__hs-asset-idx {
175
+ font-size:0.8125rem;color:#aaa;flex-shrink:0;white-space:nowrap;width:auto;
176
+ }
177
+ .__hs-asset-name {
178
+ font-size:0.75rem;color:#5a9a5a;flex-shrink:1;min-width:0;max-width:100px;
179
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
180
+ }
181
+ .__hs-asset-name.err { color:#9a5a5a; }
182
+ .__hs-asset-name.pending { color:#888; }
183
+ .__hs-asset-remove:hover { color:#f87; }
184
+
185
+ /* info grid */
186
+ .__hs-grid {
187
+ display:grid;grid-template-columns:1fr 1fr;gap:5px;padding:6px 28px 10px;
188
+ }
189
+ .__hs-grid.cols3 { grid-template-columns:1fr 1fr 1fr; }
190
+ .__hs-cell { background:#1e1e1e;border-radius:3px;padding:5px 9px; }
191
+ .__hs-lbl { font-size:0.875rem;color:#999;text-transform:uppercase; }
192
+ .__hs-val { font-size:0.9375rem;color:#aaa;font-variant-numeric:tabular-nums; }
193
+ .__hs-val.warn { color:#f87; }
194
+ .__hs-fp-hd { font-size:0.75rem;color:#888;padding:0 28px 2px;letter-spacing:.04em;text-transform:uppercase; }
195
+ .__hs-fp-hd.none,#__hs-fp-grid.none,#__hs-fp-toggle-row.none { display:none; }
196
+ #__hs-fp-toggle-row { padding:0 28px; }
197
+ .__hs-focal-marker {
198
+ position:absolute;width:8px;height:8px;border-radius:50%;
199
+ background:#fff;border:1.5px solid #e33;
200
+ transform:translate(-50%,-50%);pointer-events:none;z-index:99998;
201
+ }
202
+
203
+ /* markers */
204
+ .__hs-empty { padding:9px 28px;color:#666;font-size:0.9375rem;font-style:italic; }
205
+
206
+ /* parts list (rendered into the splat-status modal — see openPartsModal) */
207
+ .__hs-pr { display:flex;align-items:center;gap:8px;padding:4px 28px; }
208
+ .__hs-prn {
209
+ font-size:0.9375rem;color:#aaa;flex:1;min-width:0;
210
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
211
+ }
212
+ .__hs-pb {
213
+ font-size:0.9rem;width:22px;height:22px;border-radius:50%;
214
+ display:inline-flex;align-items:center;justify-content:center;
215
+ flex-shrink:0;background:#1e1e1e;border:1px solid #333;color:#555;
216
+ }
217
+ .__hs-pb.ok { background:#1a2a1a;border-color:#2a4a2a;color:#5a9a5a; }
218
+ .__hs-pb.err { background:#2a1a1a;border-color:#4a2a2a;color:#9a5a5a; }
219
+ .__hs-pft {
220
+ font-size:0.75rem;color:#888;background:#1e1e1e;border:1px solid #333;
221
+ border-radius:3px;padding:1px 6px;flex-shrink:0;text-transform:uppercase;
222
+ letter-spacing:.04em;
223
+ }
224
+ .__hs-plod {
225
+ font-size:0.7rem;color:#7aa6d6;background:#1e2a3e;border:1px solid #2a4a6a;
226
+ border-radius:8px;padding:1px 7px;flex-shrink:0;white-space:nowrap;
227
+ }
228
+ .__hs-plod.err { color:#9a5a5a;background:#2a1a1a;border-color:#4a2a2a; }
229
+
230
+ /* splat-status summary row (shown under the Website row and each asset row) */
231
+ .__hs-splat-row {
232
+ display:flex;align-items:center;gap:8px;padding:5px 28px 7px;
233
+ font-size:0.8125rem;color:#999;
234
+ }
235
+ .__hs-splat-row input {
236
+ flex:1;min-width:0;background:#1e1e1e;border:1px solid #2e2e2e;color:#ccc;
237
+ padding:5px 9px;border-radius:3px;font-size:0.875rem;font-family:inherit;
238
+ }
239
+ .__hs-splat-row input:focus { border-color:#3a7aff;outline:none; }
240
+ .__hs-splat-summary { display:flex;align-items:center;gap:8px;flex-shrink:0; }
241
+ .__hs-splat-count { color:#999; }
242
+ .__hs-splat-status { font-weight:600; }
243
+ .__hs-splat-status.ok { color:#5a9a5a; }
244
+ .__hs-splat-status.err { color:#e08a8a; }
245
+ .__hs-splat-status.pending { color:#777; }
246
+
247
+ /* modal */
248
+ #__hs-modal-overlay {
249
+ position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:200000;
250
+ display:flex;align-items:center;justify-content:center;
251
+ }
252
+ #__hs-modal-box {
253
+ background:#181818;border:1px solid #333;border-radius:6px;
254
+ width:420px;max-height:70vh;display:flex;flex-direction:column;
255
+ box-shadow:0 8px 32px rgba(0,0,0,0.5);
256
+ }
257
+ #__hs-modal-hd {
258
+ display:flex;align-items:center;justify-content:space-between;
259
+ padding:12px 16px;border-bottom:1px solid #2a2a2a;flex-shrink:0;
260
+ }
261
+ #__hs-modal-title { font-size:0.9375rem;font-weight:700;color:#eee; }
262
+ #__hs-modal-close {
263
+ background:none;border:none;color:#888;font-size:1.1rem;
264
+ cursor:pointer;padding:0 4px;font-family:inherit;
265
+ }
266
+ #__hs-modal-close:hover { color:#eee; }
267
+ #__hs-modal-body { overflow-y:auto;padding:6px 0; }
268
+
269
+ /* mask feather-edit modal body (see renderMasksEditBody) */
270
+ .__hs-mask-edit-body { padding:0 16px 8px; }
271
+ .__hs-mask-master-row .__hs-attr-lbl { color:#eee;font-weight:600; }
272
+ .__hs-mask-divider { height:1px;background:#2a2a2a;margin:4px 0 6px; }
273
+
274
+ #__hs-global-cfg { padding:6px 28px; }
275
+ #__hs-global-cfg .__hs-attr-row { padding:3px 0; }
276
+ #__hs-global-cfg .__hs-attr-lbl { font-size:0.8125rem;color:#888; }
277
+
278
+ /* scene cards */
279
+ #__hs-ed .__hs-scard { border-bottom:1px solid rgba(255,255,255,0.12); margin:0 10px; }
280
+ .__hs-scard-hd {
281
+ position:relative;overflow:hidden;isolation:isolate;
282
+ display:flex;align-items:center;gap:8px;
283
+ padding:8px 12px 8px 8px;cursor:pointer;user-select:none;
284
+ }
285
+ .__hs-scard-hd:hover { background:#1d1d1d; }
286
+ .__hs-scard--active > .__hs-scard-hd {
287
+ box-shadow:inset 2px 0 0 rgba(58,122,255,0.65);
288
+ }
289
+ .__hs-scard-bar {
290
+ position:absolute;left:0;top:0;bottom:0;width:0%;
291
+ background:rgba(40,80,200,0.28);pointer-events:none;z-index:-1;
292
+ transition:width 0.1s linear;
293
+ }
294
+ .__hs-scard-play {
295
+ flex-shrink:0;width:20px;height:20px;padding:0;
296
+ display:flex;align-items:center;justify-content:center;
297
+ background:none;border:none;cursor:pointer;
298
+ font-size:0.6rem;color:#4a8aff;
299
+ }
300
+ .__hs-scard-play:hover { color:#A9C6F5; }
301
+ .__hs-scard-play.playing { color:#fff;font-size:0.85rem; }
302
+ .__hs-scard-play.paused { color:#fff;font-size:0.85rem; }
303
+ .__hs-scard-nm { font-size:0.9375rem;color:#ccc;flex:1;cursor:pointer; }
304
+ .__hs-scard-nm:hover { color:#fff; }
305
+ .__hs-scard--active .__hs-scard-nm { cursor:ew-resize; }
306
+ .__hs-scard-fr { font-size:0.8125rem;color:#888;font-variant-numeric:tabular-nums;flex-shrink:0; }
307
+ .__hs-scard-bd { padding:4px 28px 14px 36px;display:none; }
308
+ .__hs-scard-bd.open { display:block; }
309
+
310
+ /* scene name label on the player canvas */
311
+ .__hs-scene-lbl {
312
+ position:absolute;bottom:14px;left:50%;transform:translateX(-50%);
313
+ background:rgba(0,0,0,0.52);color:rgba(255,255,255,0.72);
314
+ font-size:0.8125rem;padding:3px 11px;border-radius:3px;
315
+ pointer-events:none;font-family:system-ui,sans-serif;
316
+ z-index:10;white-space:nowrap;letter-spacing:0.04em;
317
+ }
318
+
319
+ .__hs-stri { color:#666;font-size:0.7rem;width:12px;flex-shrink:0;transition:transform .15s; }
320
+
321
+ .__hs-sinf-hd { display:flex;align-items:center;gap:6px;padding:5px 0 3px;cursor:pointer;user-select:none; }
322
+ .__hs-sinf-nm { font-size:0.8rem;color:#888;text-transform:uppercase;letter-spacing:.06em;flex:1; }
323
+ .__hs-sinf-bd { padding-left:14px;padding-bottom:6px;display:none; }
324
+ .__hs-sinf-bd.open { display:block; }
325
+ .__hs-srow { display:grid;grid-template-columns:80px 1fr;padding:2px 0; }
326
+ .__hs-srow span:first-child { font-size:0.8125rem;color:#888; }
327
+ .__hs-srow span:last-child { font-size:0.8125rem;color:#777; }
328
+
329
+ #__hs-ed .__hs-sdiv { border-top:1px solid rgba(255,255,255,0.15);margin:10px 10px; }
330
+
331
+ .__hs-sel-row { display:flex;align-items:center;gap:10px;padding:5px 0; }
332
+ .__hs-sel-lbl { font-size:0.875rem;color:#aaa;flex-shrink:0;min-width:90px; }
333
+ .__hs-el-sel {
334
+ background:#1e1e1e;border:1px solid #2e2e2e;color:#ccc;
335
+ padding:4px 6px;border-radius:3px;font-size:0.875rem;font-family:inherit;flex:1;
336
+ }
337
+ .__hs-el-sel:focus { border-color:#3a7aff;outline:none; }
338
+
339
+ .__hs-drop-btn {
340
+ background:#1e1e1e;border:1px solid #2e2e2e;color:#ccc;
341
+ padding:4px 6px;border-radius:3px;font-size:0.875rem;font-family:inherit;
342
+ cursor:pointer;text-align:left;flex:1;min-width:0;
343
+ }
344
+ .__hs-drop-btn:hover { border-color:#444; }
345
+ .__hs-drop-menu {
346
+ position:fixed;background:#1e1e1e;border:1px solid #3a3a3a;
347
+ border-radius:3px;box-shadow:0 4px 16px rgba(0,0,0,.6);
348
+ z-index:999999;padding:2px 0;
349
+ }
350
+ .__hs-drop-item {
351
+ padding:5px 12px;font-size:0.875rem;color:#ccc;cursor:pointer;
352
+ font-family:system-ui,sans-serif;white-space:nowrap;
353
+ }
354
+ .__hs-drop-item:hover { background:#2a2a2a;color:#fff; }
355
+ .__hs-drop-item.active { color:#3a7aff; }
356
+
357
+ .__hs-chkrow { display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer; }
358
+ .__hs-chkrow label { font-size:0.9375rem;color:#888;cursor:pointer;flex:1; }
359
+
360
+ .__hs-ablock { padding:2px 0; }
361
+ .__hs-ablk-hd { display:flex;align-items:center;gap:8px;padding:5px 0;cursor:pointer;user-select:none; }
362
+ .__hs-ablk-nm { font-size:0.9375rem;color:#888;flex:1; }
363
+ .__hs-ablk-bd { padding-left:20px;display:none;padding-bottom:4px; }
364
+ .__hs-ablk-bd.open { display:block; }
365
+
366
+ .__hs-sub-chk { display:flex;align-items:center;gap:7px;padding:3px 0;cursor:pointer; }
367
+ .__hs-sub-chk label { font-size:0.875rem;color:#777;cursor:pointer; }
368
+ .__hs-radio-grp { display:flex;gap:20px;padding:4px 0 2px; }
369
+ .__hs-radio-grp label { display:flex;align-items:center;gap:5px;font-size:0.875rem;color:#777;cursor:pointer; }
370
+ .__hs-deg-row { display:flex;align-items:center;gap:8px;padding:3px 0; }
371
+ .__hs-deg-chk { display:flex;align-items:center;gap:6px;min-width:90px;cursor:pointer; }
372
+ .__hs-deg-chk label { font-size:0.875rem;color:#aaa;cursor:pointer; }
373
+ .__hs-ninp {
374
+ width:60px;background:#1e1e1e;border:1px solid #2e2e2e;color:#ccc;
375
+ padding:3px 7px;border-radius:3px;font-size:0.875rem;font-family:inherit;text-align:right;
376
+ cursor:ew-resize;
377
+ -moz-appearance:textfield;
378
+ }
379
+ .__hs-ninp:focus { border-color:#3a7aff;outline:none;cursor:text; }
380
+ .__hs-ninp.dragging { cursor:grabbing; }
381
+ .__hs-ninp::-webkit-inner-spin-button,
382
+ .__hs-ninp::-webkit-outer-spin-button { -webkit-appearance:none;margin:0; }
383
+ .__hs-deg-unit { font-size:0.8125rem;color:#aaa; }
384
+ .__hs-ninp:disabled,.__hs-deg-chk label.__hs-dim { color:#333; }
385
+ .__hs-ninp:disabled { border-color:#222;color:#333; }
386
+
387
+ .__hs-toggle { position:relative;display:inline-block;width:32px;height:18px;flex-shrink:0;cursor:pointer; }
388
+ .__hs-toggle input { opacity:0;width:0;height:0;position:absolute; }
389
+ .__hs-toggle-track { position:absolute;inset:0;background:#2a2a2a;border:1px solid #3a3a3a;border-radius:9px;transition:background .2s,border-color .2s; }
390
+ .__hs-toggle input:checked + .__hs-toggle-track { background:#1e3a6e;border-color:#3a6aff; }
391
+ .__hs-toggle-thumb { position:absolute;top:3px;left:2px;width:12px;height:12px;background:#555;border-radius:50%;transition:transform .2s,background .2s;pointer-events:none; }
392
+ .__hs-toggle input:checked ~ .__hs-toggle-thumb { transform:translateX(14px);background:#3a7aff; }
393
+
394
+ .__hs-attr-row { display:flex;align-items:center;padding:5px 0;min-height:28px; }
395
+ .__hs-attr-lbl { font-size:0.875rem;color:#aaa;flex:1; }
396
+ .__hs-num-wrap { display:flex;align-items:center;gap:6px; }
397
+ .__hs-scard-range { font-size:0.8125rem;color:#888;font-variant-numeric:tabular-nums;flex-shrink:0; }
398
+ .__hs-scard-dot {
399
+ width:5px;height:5px;border-radius:50%;flex-shrink:0;
400
+ background:transparent;transition:background .15s;
401
+ }
402
+ .__hs-scard--configured .__hs-scard-dot { background:#4a8aff; }
403
+ .__hs-scard-linked {
404
+ font-size:0.6rem;font-family:monospace;letter-spacing:0;
405
+ background:rgba(58,122,255,0.12);color:#5a9aff;
406
+ border:1px solid rgba(58,122,255,0.25);border-radius:2px;
407
+ padding:1px 3px;flex-shrink:0;line-height:1.4;display:none;
408
+ }
409
+ .__hs-scard-linked.has-id { display:inline-block; }
410
+
411
+ /* timeline (floating bottom bar) */
412
+ #__hs-tl {
413
+ position:fixed;bottom:20px;left:20px;right:20px;z-index:99997;
414
+ height:160px;background:#1c1e22;border:1px solid #282828;border-radius:20px;
415
+ box-shadow:0 8px 32px rgba(0,0,0,.5);
416
+ font-family:system-ui,sans-serif;box-sizing:border-box;padding:0 20px;
417
+ pointer-events:auto;user-select:none;overflow:hidden;transition:height .15s;
418
+ }
419
+ #__hs-tl.collapsed { height:56px;display:flex;align-items:center; }
420
+ #__hs-tl.collapsed #__hs-tl-btns,
421
+ #__hs-tl.collapsed #__hs-tl-labels,
422
+ #__hs-tl.collapsed #__hs-tl-footer,
423
+ #__hs-tl.collapsed #__hs-tl-meta { display:none; }
424
+ #__hs-tl.collapsed #__hs-tl-track { flex:1; }
425
+ #__hs-tl-min {
426
+ position:fixed;left:20px;bottom:calc(20px + var(--hs-tl-h, 160px));z-index:99998;
427
+ pointer-events:auto;
428
+ background:#1c1e22;border:1px solid #282828;border-bottom:none;border-radius:8px 8px 0 0;
429
+ color:#666;cursor:pointer;line-height:1;
430
+ font-size:1.25rem;padding:4px 11px;font-family:inherit;
431
+ transition:color .12s,bottom .15s;
432
+ }
433
+ #__hs-tl-min:hover { color:#ccc; }
434
+ #__hs-tl-btns {
435
+ display:flex;justify-content:center;align-items:center;gap:2px;padding:10px 0 6px;
436
+ }
437
+ .__hs-tl-btn {
438
+ background:none;border:none;color:#4a8aff;cursor:pointer;
439
+ font-size:1rem;padding:3px 10px;border-radius:3px;font-family:inherit;line-height:1;
440
+ transition:color .12s;
441
+ }
442
+ .__hs-tl-btn:hover { color:#A9C6F5; }
443
+ .__hs-tl-btn.playing { color:#fff; }
444
+ .__hs-tl-btn.paused { color:#fff; }
445
+ #__hs-tl-track { position:relative; }
446
+ #__hs-tl-labels { position:relative;height:36px;overflow:visible;margin-bottom:4px; }
447
+ .__hs-tl-seg-lbl {
448
+ position:absolute;top:0;
449
+ font-size:0.6875rem;color:#888;white-space:nowrap;
450
+ padding:2px 5px;border-radius:2px;line-height:1.4;cursor:pointer;
451
+ transform:translateX(-1px);transition:color .1s,background .1s;
452
+ }
453
+ .__hs-tl-seg-lbl:hover { background:rgba(58,122,255,0.18);color:#ddd; }
454
+ .__hs-tl-seg-lbl.configured { color:#4a8aff; }
455
+ .__hs-tl-seg-lbl.configured:hover { background:rgba(58,122,255,0.22);color:#80aaff; }
456
+ .__hs-tl-seg-lbl.active { background:#3a7aff;color:#fff; }
457
+ #__hs-tl-bar {
458
+ position:relative;height:7px;background:#252525;border-radius:2px;cursor:pointer;overflow:hidden;
459
+ }
460
+ #__hs-tl-fill {
461
+ position:absolute;left:0;top:0;bottom:0;width:0;
462
+ background:#3a7aff;pointer-events:none;
463
+ }
464
+ .__hs-tl-mark {
465
+ position:absolute;top:0;bottom:0;width:1px;background:#333;pointer-events:none;z-index:1;
466
+ }
467
+ .__hs-tl-scenedot {
468
+ position:absolute;bottom:0;left:0;width:7px;height:7px;border-radius:50%;
469
+ background:#475569;transform:translateX(-50%);pointer-events:none;z-index:2;
470
+ }
471
+ .__hs-tl-scenedot.configured { background:#4a8aff; }
472
+ #__hs-tl-footer {
473
+ display:flex;justify-content:center;gap:12px;padding:6px 0 6px;
474
+ }
475
+ #__hs-flbl { font-size:0.8125rem;color:#aaa;font-variant-numeric:tabular-nums; }
476
+ #__hs-scene-nm { font-size:0.8125rem;color:#3a7aff;opacity:0.8;letter-spacing:0.03em; }
477
+ #__hs-tl-meta {
478
+ position:absolute;right:20px;top:10px;
479
+ font-size:0.75rem;color:#888;text-align:right;
480
+ display:flex;flex-direction:column;gap:3px;pointer-events:none;
481
+ }
482
+ .__hs-tl-dot { color:#3a7aff;font-size:0.6875rem; }
483
+
484
+ /* blend-zone visualizer — overlaid on the live page's linked scene
485
+ container while dragging/editing a blend in/out value; not a toggle,
486
+ purely transient feedback. Lives outside #__hs-ed (appended to
487
+ document.body), positioned via the linked element's own rect. */
488
+ #__hs-blend-overlay { position:fixed;pointer-events:none;z-index:99998;display:none; }
489
+ .__hs-blend-zone { position:absolute;left:0;right:0;background:rgba(80,170,255,0.28); }
490
+ .__hs-blend-zone-top { top:0; }
491
+ .__hs-blend-zone-bot { bottom:0; }
492
+ `;
493
+
494
+ // ── HTML template ────────────────────────────────────────────────────────────
495
+ const HTML = `
496
+ <div id="__hs-ed">
497
+ <button id="__hs-tab" title="Toggle HoloSplat editor">HS EDITOR</button>
498
+ <div id="__hs-panel">
499
+
500
+ <div id="__hs-tb">
501
+ <h1>HoloSplat</h1>
502
+ <span id="__hs-st">connecting…</span>
503
+ <span id="__hs-badge" class="off">API offline</span>
504
+ <button class="__hs-btn" id="__hs-min" title="Minimize panel">−</button>
505
+ </div>
506
+
507
+ <div id="__hs-tabs">
508
+ <button class="__hs-tabbtn active" data-tab="scenes">Scenes</button>
509
+ <button class="__hs-tabbtn" data-tab="setup">Setup</button>
510
+ <button class="__hs-tabbtn" data-tab="tools">Tools</button>
511
+ </div>
512
+
513
+ <div id="__hs-body">
514
+
515
+ <div class="__hs-tabpanel active" data-tab="scenes">
516
+ <div class="__hs-cpane">
517
+ <div class="__hs-cpane-body">
518
+ <div id="__hs-scenes-list"><div class="__hs-empty">No animation loaded</div></div>
519
+ </div>
520
+ </div>
521
+ </div>
522
+
523
+ <div class="__hs-tabpanel" data-tab="setup">
524
+ <div class="__hs-cpane">
525
+ <div class="__hs-cpane-hd">
526
+ <span class="__hs-cpane-tri">▼</span>
527
+ <span class="__hs-cpane-title">Render</span>
528
+ </div>
529
+ <div class="__hs-cpane-body">
530
+ <div class="__hs-grid">
531
+ <div class="__hs-cell"><div class="__hs-lbl">Sort</div><div class="__hs-val" id="__hs-sortmode" title="GPU compute-shader radix sort vs CPU fallback (see src/renderer.js _gpuSortFailed)">—</div></div>
532
+ <div class="__hs-cell"><div class="__hs-lbl">Pixel ratio</div><div class="__hs-val" id="__hs-pxratio" title="Adaptive quality's current render scale vs its max (drops under sustained low fps, eases back up — see Viewer#_updateAdaptiveQuality)">—</div></div>
533
+ </div>
534
+ <div id="__hs-global-cfg"></div>
535
+ </div>
536
+ </div>
537
+
538
+ <div class="__hs-cpane">
539
+ <div class="__hs-cpane-hd">
540
+ <span class="__hs-cpane-tri">▼</span>
541
+ <span class="__hs-cpane-title">Files</span>
542
+ </div>
543
+ <div class="__hs-cpane-body">
544
+ <div class="__hs-sub-pt">Main Animation</div>
545
+ <div class="__hs-fr">
546
+ <input id="__hs-an" placeholder="/scenes/anim.json" spellcheck="false" title="Main website animation JSON (camera + scene timeline)">
547
+ <button class="__hs-btn" id="__hs-ran" title="Reload animation">↺</button>
548
+ </div>
549
+ <div class="__hs-splat-row">
550
+ <input id="__hs-pd" placeholder="/scenes/headphones/" spellcheck="false" title="Directory for per-object splat files — one .spz per animation object">
551
+ <button class="__hs-btn" id="__hs-sync" title="Load parts from animation and update HTML">↺</button>
552
+ <span class="__hs-splat-summary" id="__hs-website-splat-summary"></span>
553
+ </div>
554
+ <div class="__hs-splat-row">
555
+ <span class="__hs-fl" style="width:auto">Masks</span>
556
+ <span class="__hs-splat-summary" id="__hs-website-masks-summary"></span>
557
+ </div>
558
+ <div class="__hs-sub-pt">Assets</div>
559
+ <div id="__hs-assets-list"></div>
560
+ <div class="__hs-fr">
561
+ <button class="__hs-btn" id="__hs-asset-add" title="Add an asset clip file">+ Add asset</button>
562
+ </div>
563
+ </div>
564
+ </div>
565
+
566
+ <div class="__hs-cpane">
567
+ <div class="__hs-cpane-hd">
568
+ <span class="__hs-cpane-tri">▼</span>
569
+ <span class="__hs-cpane-title">3D Scene</span>
570
+ </div>
571
+ <div class="__hs-cpane-body">
572
+ <div class="__hs-grid">
573
+ <div class="__hs-cell"><div class="__hs-lbl">Splats</div><div class="__hs-val" id="__hs-nsplats">—</div></div>
574
+ <div class="__hs-cell"><div class="__hs-lbl">Status</div><div class="__hs-val" id="__hs-loadst">—</div></div>
575
+ <div class="__hs-cell"><div class="__hs-lbl">FlipY</div><div class="__hs-val" id="__hs-flipy">—</div></div>
576
+ <div class="__hs-cell"><div class="__hs-lbl">Camera</div><div class="__hs-val" id="__hs-cammode">—</div></div>
577
+ </div>
578
+ <div class="__hs-sub-pt">Camera</div>
579
+ <div class="__hs-grid cols3">
580
+ <div class="__hs-cell"><div class="__hs-lbl">theta °</div><div class="__hs-val" id="__hs-cth">—</div></div>
581
+ <div class="__hs-cell"><div class="__hs-lbl">phi °</div><div class="__hs-val" id="__hs-cph">—</div></div>
582
+ <div class="__hs-cell"><div class="__hs-lbl">radius</div><div class="__hs-val" id="__hs-cr">—</div></div>
583
+ <div class="__hs-cell"><div class="__hs-lbl">tx</div><div class="__hs-val" id="__hs-ctx">—</div></div>
584
+ <div class="__hs-cell"><div class="__hs-lbl">ty</div><div class="__hs-val" id="__hs-cty">—</div></div>
585
+ <div class="__hs-cell"><div class="__hs-lbl">tz</div><div class="__hs-val" id="__hs-ctz">—</div></div>
586
+ </div>
587
+ <div class="__hs-fp-hd" id="__hs-fp-hd"><span>Focal Point</span></div>
588
+ <div class="__hs-grid cols3" id="__hs-fp-grid">
589
+ <div class="__hs-cell"><div class="__hs-lbl">x</div><div class="__hs-val" id="__hs-fpx">—</div></div>
590
+ <div class="__hs-cell"><div class="__hs-lbl">y</div><div class="__hs-val" id="__hs-fpy">—</div></div>
591
+ <div class="__hs-cell"><div class="__hs-lbl">z</div><div class="__hs-val" id="__hs-fpz">—</div></div>
592
+ </div>
593
+ <div id="__hs-fp-toggle-row"></div>
594
+ <div class="__hs-sub-pt">Masks</div>
595
+ <div id="__hs-masks-list"><div class="__hs-empty">No mask volumes</div></div>
596
+ </div>
597
+ </div>
598
+ </div>
599
+
600
+ <div class="__hs-tabpanel" data-tab="tools">
601
+ <div class="__hs-cpane">
602
+ <div class="__hs-cpane-body">
603
+ <div class="__hs-fr" style="padding-top:10px;padding-bottom:6px">
604
+ <button class="__hs-btn" id="__hs-init" title="Inject hs-player into this page">Init page</button>
605
+ <a class="__hs-btn __hs-link" href="/examples/compress.html" target="_blank" title="Compress .splat/.ply files to .spz">Compress</a>
606
+ <a class="__hs-btn __hs-link" href="/examples/prune.html" target="_blank" title="Prune low-impact splats / generate LOD tiers">Prune / LOD</a>
607
+ <a class="__hs-btn __hs-link" href="/examples/pack.html" target="_blank" title="Pack color/material variants of the same model into one .spzv">Pack Variants</a>
608
+ </div>
609
+ </div>
610
+ </div>
611
+ </div>
612
+
613
+ </div><!-- /#__hs-body -->
614
+
615
+ </div>
616
+ </div>`;
617
+
618
+ // ── DOM helpers ─────────────────────────────────────────────────────────────
619
+ const el = id => document.getElementById('__hs-' + id);
620
+ const fmt = n => typeof n === 'number' ? n.toFixed(2) : '—';
621
+
622
+ function setStatus(msg, err = false) {
623
+ el('st').textContent = msg;
624
+ el('st').className = err ? 'err' : '';
625
+ }
626
+
627
+ // ── API ─────────────────────────────────────────────────────────────────────
628
+ async function apiCheck() {
629
+ try {
630
+ const res = await fetch('/hs-api/ls');
631
+ S.apiOnline = res.ok;
632
+ if (res.ok) S.diskFiles = new Set((await res.json()).spz.map(p => '/' + p));
633
+ } catch { S.apiOnline = false; }
634
+ el('badge').textContent = S.apiOnline ? 'API online' : 'API offline';
635
+ el('badge').className = S.apiOnline ? '' : 'off';
636
+ }
637
+
638
+ // Re-fetches the on-disk splat file listing. Call only on an explicit
639
+ // splats-dir change/reload — never per-file, never on every render (that's
640
+ // what caused the editor to flood the server with 404 HEAD probes before).
641
+ async function refreshDiskFiles() {
642
+ if (!S.apiOnline) return;
643
+ try {
644
+ const res = await fetch('/hs-api/ls');
645
+ if (res.ok) S.diskFiles = new Set((await res.json()).spz.map(p => '/' + p));
646
+ } catch { /* keep stale listing rather than wiping it on a transient error */ }
647
+ }
648
+
649
+ // ── Connect to player ────────────────────────────────────────────────────────
650
+ function connectEntry(entry) {
651
+ S.entry = entry;
652
+ const { root, api, viewer } = entry;
653
+
654
+ // Pre-populate S.sceneConfigs from the player's in-memory configs.
655
+ // window.__hsSceneConfigs is set by player.js from the saved `scenes:` JS variable,
656
+ // so it already reflects the last persisted state without needing an API call.
657
+ {
658
+ const live = window.__hsSceneConfigs || {};
659
+ for (const [name, raw] of Object.entries(live)) {
660
+ if (!S.sceneConfigs[name]) S.sceneConfigs[name] = mergeWithDefault(raw);
661
+ }
662
+ }
663
+
664
+ // Pre-populate S.maskConfigs the same way, from window.__hsMaskConfigs
665
+ // (set by player.js from the saved `masks:` JS variable).
666
+ {
667
+ const live = window.__hsMaskConfigs || {};
668
+ for (const [name, raw] of Object.entries(live)) {
669
+ if (!S.maskConfigs[name]) S.maskConfigs[name] = { ...raw };
670
+ }
671
+ }
672
+
673
+ S.partsKey = ''; // reset so tick re-renders badges immediately after connect
674
+ el('pd').value = '';
675
+ el('an').value = '';
676
+ el('flipy').textContent = viewer._flipY ? 'yes' : 'no';
677
+
678
+ // Inject scene-name label into the player canvas area
679
+ const lbl = document.createElement('div');
680
+ lbl.className = '__hs-scene-lbl';
681
+ lbl.style.display = 'none';
682
+ root.appendChild(lbl);
683
+ S.sceneLbl = lbl;
684
+
685
+ // Focal-point debug marker — white circle, red border, toggled via the
686
+ // "Focal pt" checkbox in Setup ▸ 3D Scene. Lets you visually confirm
687
+ // whether orbiting is actually pivoting around the focal point.
688
+ const fpMarker = document.createElement('div');
689
+ fpMarker.className = '__hs-focal-marker';
690
+ fpMarker.style.display = 'none';
691
+ root.appendChild(fpMarker);
692
+ S.focalMarkerEl = fpMarker;
693
+
694
+ // Read HTML file + animation JSON → populate fields and render scenes immediately.
695
+ // Await _apiReady so S.apiOnline is known before loadPageState's HTML read.
696
+ (_apiReady || Promise.resolve()).then(() => loadPageState().then(() => {
697
+ renderScenes(); renderMasks();
698
+ renderAssetsList(); loadAllAssets();
699
+ }));
700
+
701
+ if (api.animation) {
702
+ connectAnim(api.animation);
703
+ } else {
704
+ // Poll until animation loads
705
+ const poll = setInterval(() => {
706
+ if (api.animation) { clearInterval(poll); connectAnim(api.animation); }
707
+ }, 250);
708
+ }
709
+
710
+ setInterval(tick, 250);
711
+ setStatus('Connected');
712
+ }
713
+
714
+ function connectAnim(anim) {
715
+ S.markers = anim.markers || {};
716
+ S.frameCount = anim.frameCount || 0;
717
+ el('range').max = Math.max(0, S.frameCount - 1);
718
+ el('range').value = 0;
719
+ el('flbl').textContent = `0 / ${S.frameCount}`;
720
+ renderTimeline();
721
+ renderParts();
722
+ // Read saved attrs from HTML file before building scene cards so the editor
723
+ // always reflects the persisted state, not just the in-memory DOM.
724
+ (_apiReady || Promise.resolve()).then(() => loadPageState().then(() => { renderScenes(); renderMasks(); }));
725
+ setStatus(`${S.frameCount} frames · ${Object.keys(S.markers).length} markers`);
726
+ }
727
+
728
+ function tick() {
729
+ if (!S.entry) return;
730
+ const { api, viewer } = S.entry;
731
+ const cam = api.camera;
732
+
733
+ // Scene info
734
+ el('nsplats').textContent = viewer._numSplats ? viewer._numSplats.toLocaleString() : '0';
735
+ el('loadst').textContent = viewer._sceneReady ? 'ready' : 'loading…';
736
+ el('cammode').textContent = viewer._cameraFree ? 'free' : 'anim';
737
+
738
+ // Sort mode — _gpuSortFailed is a one-way latch (see src/renderer.js):
739
+ // once a GPU validation error fires, this viewer instance is stuck on
740
+ // the CPU sorter (slower, softer image) until the page is reloaded.
741
+ const sortEl = el('sortmode');
742
+ if (sortEl) {
743
+ const gpuFailed = !!viewer._renderer?._gpuSortFailed;
744
+ const gpuOn = !!viewer._gpuSort && !gpuFailed;
745
+ sortEl.textContent = gpuOn ? 'GPU' : (viewer._gpuSort ? 'CPU (fallback)' : 'CPU');
746
+ sortEl.classList.toggle('warn', viewer._gpuSort && gpuFailed);
747
+ }
748
+
749
+ // Adaptive-quality render scale — see Viewer#_updateAdaptiveQuality.
750
+ const pxEl = el('pxratio');
751
+ if (pxEl && viewer._maxPixelRatio) {
752
+ const pct = Math.round(100 * viewer._effectivePixelRatio / viewer._maxPixelRatio);
753
+ pxEl.textContent = `${viewer._effectivePixelRatio.toFixed(2)} (${pct}%)`;
754
+ pxEl.classList.toggle('warn', pct < 90);
755
+ }
756
+
757
+ // Camera
758
+ if (cam) {
759
+ el('cth').textContent = fmt(cam.theta * 180 / Math.PI) + '°';
760
+ el('cph').textContent = fmt(cam.phi * 180 / Math.PI) + '°';
761
+ el('cr').textContent = fmt(cam.radius);
762
+ el('ctx').textContent = fmt(cam.target[0]);
763
+ el('cty').textContent = fmt(cam.target[1]);
764
+ el('ctz').textContent = fmt(cam.target[2]);
765
+ }
766
+
767
+ // Focal point (per-frame when animated)
768
+ const fp = api.animation?.getFocalPoint();
769
+ const hasFp = fp != null;
770
+ el('fp-hd').classList.toggle('none', !hasFp);
771
+ el('fp-grid').classList.toggle('none', !hasFp);
772
+ el('fp-toggle-row').classList.toggle('none', !hasFp);
773
+ if (hasFp) {
774
+ el('fpx').textContent = fmt(fp[0]);
775
+ el('fpy').textContent = fmt(fp[1]);
776
+ el('fpz').textContent = fmt(fp[2]);
777
+ }
778
+
779
+ // Scrubber follow
780
+ const anim = api.animation;
781
+ if (anim && !S.scrubbing) {
782
+ const f = Math.round(anim.frame || 0);
783
+ el('range').value = f;
784
+ el('flbl').textContent = `${f} / ${S.frameCount}`;
785
+ }
786
+
787
+ // Auto-connect animation if it just loaded
788
+ if (anim && S.frameCount === 0) connectAnim(anim);
789
+
790
+ // Re-render parts badges whenever the loaded-parts set changes.
791
+ const partsKey = Object.keys(viewer._partIndex || {}).sort().join('\n');
792
+ if (partsKey !== S.partsKey) { S.partsKey = partsKey; renderParts(); }
793
+
794
+ // Active scene: update player label + scene list highlight
795
+ const active = getActiveMarker();
796
+ if (active !== S.activeMarker) {
797
+ S.activeMarker = active;
798
+ if (S.sceneLbl) {
799
+ S.sceneLbl.textContent = active || '';
800
+ S.sceneLbl.style.display = active ? '' : 'none';
801
+ }
802
+ const nm = el('scene-nm');
803
+ if (nm) nm.textContent = active || '';
804
+ const list = el('scenes-list');
805
+ if (list) {
806
+ for (const card of list.querySelectorAll('.__hs-scard')) {
807
+ card.classList.toggle('__hs-scard--active', card.dataset.scKey === active);
808
+ }
809
+ }
810
+ }
811
+
812
+ // Sync playback button highlight
813
+ updatePlayState();
814
+ }
815
+
816
+ // ── Timeline ─────────────────────────────────────────────────────────────────
817
+ function renderTimeline() {
818
+ const labels = el('tl-labels');
819
+ const track = el('tl-track');
820
+ if (!labels || !track) return;
821
+ labels.innerHTML = '';
822
+ for (const d of track.querySelectorAll('.__hs-tl-mark, .__hs-tl-scenedot')) d.remove();
823
+ if (!S.frameCount) return;
824
+ const sorted = Object.entries(S.markers).sort((a, b) => a[1] - b[1]);
825
+ if (!sorted.length) return;
826
+ for (let i = 0; i < sorted.length; i++) {
827
+ const [name, startF] = sorted[i];
828
+ const leftPct = startF / S.frameCount * 100;
829
+ const lbl = document.createElement('span');
830
+ lbl.className = '__hs-tl-seg-lbl';
831
+ lbl.dataset.scKey = name;
832
+ lbl.textContent = name;
833
+ lbl.style.left = leftPct + '%';
834
+ if (hasActiveConfig(S.sceneConfigs[name] || {})) lbl.classList.add('configured');
835
+ lbl.addEventListener('click', () => {
836
+ const anim = S.entry?.api.animation;
837
+ if (!anim || !S.entry) return;
838
+ anim.seekFrame(startF);
839
+ S.entry.api.setAnimationPaused(false);
840
+ S.animEverPlayed = true;
841
+ el('range').value = startF;
842
+ el('flbl').textContent = `${startF} / ${S.frameCount}`;
843
+ updatePlayState();
844
+ });
845
+ labels.appendChild(lbl);
846
+ if (i > 0) {
847
+ const div = document.createElement('div');
848
+ div.className = '__hs-tl-mark';
849
+ div.style.left = leftPct + '%';
850
+ track.appendChild(div);
851
+ }
852
+ // Per-scene dot on the slider — the only marker visible when collapsed.
853
+ const dot = document.createElement('div');
854
+ dot.className = '__hs-tl-scenedot';
855
+ dot.style.left = leftPct + '%';
856
+ if (hasActiveConfig(S.sceneConfigs[name] || {})) dot.classList.add('configured');
857
+ track.appendChild(dot);
858
+ }
859
+ requestAnimationFrame(() => adjustLabelOverlaps(labels));
860
+ }
861
+
862
+ // After labels are in the DOM, check for horizontal overlaps and stack
863
+ // colliding labels upward so they stay readable.
864
+ function adjustLabelOverlaps(container) {
865
+ if (!container) return;
866
+ const lbls = [...container.querySelectorAll('.__hs-tl-seg-lbl')];
867
+ if (lbls.length < 2) { container.style.height = ''; return; }
868
+ // Container is hidden (e.g. timeline collapsed) — all rects collapse to
869
+ // 0x0, which would make every label "overlap" and stack into N separate
870
+ // rows. Skip until it's actually laid out (re-run on expand instead).
871
+ if (container.getBoundingClientRect().width === 0) return;
872
+ lbls.sort((a, b) => a.getBoundingClientRect().left - b.getBoundingClientRect().left);
873
+ const rowRights = [-Infinity]; // rightmost pixel reached in each row (row 0 = baseline)
874
+ for (const lbl of lbls) {
875
+ const rect = lbl.getBoundingClientRect();
876
+ let row = 0;
877
+ while (row < rowRights.length && rowRights[row] > rect.left - 2) row++;
878
+ if (row >= rowRights.length) rowRights.push(-Infinity);
879
+ rowRights[row] = rect.right;
880
+ lbl.style.top = row > 0 ? `${row * 20}px` : '0';
881
+ }
882
+ // Grow the labels area to fit stacked rows so they don't overlap the bar below.
883
+ container.style.height = rowRights.length > 1 ? `${rowRights.length * 20 + 16}px` : '';
884
+ }
885
+
886
+ let _fpsCount = 0, _fpsLast = 0;
887
+
888
+ // Runs at rAF rate to keep scene card progress bars, play buttons, and timeline smooth
889
+ function rafBars() {
890
+ requestAnimationFrame(rafBars);
891
+ if (!S.entry) return;
892
+ const frame = S.entry.api.animation?.frame ?? 0;
893
+ const paused = S.entry.viewer._animPaused;
894
+ const active = S.activeMarker;
895
+
896
+ // Focal-point debug marker — reuses the same screen-space projection as
897
+ // Blender-exported callouts (Viewer#projectCallouts) so it tracks the
898
+ // current view exactly, including behind-camera hiding.
899
+ if (S.showFocalMarker && S.focalMarkerEl) {
900
+ const fp = S.entry.api.animation?.getFocalPoint();
901
+ const proj = fp ? S.entry.viewer.projectCallouts([{ id: '__focal', pos: fp }])[0] : null;
902
+ if (proj?.visible) {
903
+ S.focalMarkerEl.style.display = '';
904
+ S.focalMarkerEl.style.left = proj.x + 'px';
905
+ S.focalMarkerEl.style.top = proj.y + 'px';
906
+ } else {
907
+ S.focalMarkerEl.style.display = 'none';
908
+ }
909
+ }
910
+
911
+ // Scene card bars
912
+ for (const [key, cd] of Object.entries(S.sceneCards)) {
913
+ const isActive = key === active;
914
+ const pct = isActive
915
+ ? Math.max(0, Math.min(100, (frame - cd.from) / cd.frames * 100))
916
+ : 0;
917
+ if (cd._pct !== pct) { cd._pct = pct; cd.barEl.style.width = pct + '%'; }
918
+ const isPlay = isActive && !paused;
919
+ const isPaused = isActive && paused && S.animEverPlayed;
920
+ const showStop = isPlay || isPaused;
921
+ if (cd._playing !== showStop) {
922
+ cd._playing = showStop;
923
+ cd.playBtn.textContent = showStop ? '⏸' : '▶';
924
+ cd.playBtn.classList.toggle('playing', isPlay);
925
+ cd.playBtn.classList.toggle('paused', isPaused);
926
+ }
927
+ }
928
+
929
+ // Timeline progress fill
930
+ const tlFill = el('tl-fill');
931
+ if (tlFill && S.frameCount > 1) {
932
+ const pct = Math.max(0, Math.min(100, frame / (S.frameCount - 1) * 100));
933
+ if (tlFill.__pct !== pct) { tlFill.__pct = pct; tlFill.style.width = pct + '%'; }
934
+ }
935
+
936
+ // Timeline active scene label
937
+ const tlLabels = el('tl-labels');
938
+ if (tlLabels && tlLabels.__active !== active) {
939
+ tlLabels.__active = active;
940
+ for (const lbl of tlLabels.querySelectorAll('.__hs-tl-seg-lbl'))
941
+ lbl.classList.toggle('active', lbl.dataset.scKey === active);
942
+ }
943
+
944
+ // FPS counter (updates once per second)
945
+ _fpsCount++;
946
+ const now = performance.now();
947
+ if (!_fpsLast) _fpsLast = now;
948
+ if (now - _fpsLast >= 1000) {
949
+ const fps = Math.round(_fpsCount * 1000 / (now - _fpsLast));
950
+ const fpsEl = el('tl-fps');
951
+ if (fpsEl) fpsEl.textContent = fps;
952
+ _fpsCount = 0;
953
+ _fpsLast = now;
954
+ }
955
+ }
956
+
957
+ function updatePlayState() {
958
+ if (!S.entry) return;
959
+ const paused = S.entry.viewer._animPaused;
960
+ const reverse = S.entry.api.animation?.direction === -1;
961
+
962
+ const fwdBtn = el('playpause');
963
+ const bwdBtn = el('rev');
964
+ if (fwdBtn) {
965
+ const fwdPlaying = !paused && !reverse;
966
+ const fwdPaused = paused && !reverse && S.animEverPlayed;
967
+ fwdBtn.textContent = (fwdPlaying || fwdPaused) ? '⏸' : '▶';
968
+ fwdBtn.title = fwdPlaying ? 'Pause' : 'Play';
969
+ fwdBtn.classList.toggle('playing', fwdPlaying);
970
+ fwdBtn.classList.toggle('paused', fwdPaused);
971
+ }
972
+ if (bwdBtn) {
973
+ const bwdPlaying = !paused && reverse;
974
+ const bwdPaused = paused && reverse && S.animEverPlayed;
975
+ bwdBtn.textContent = (bwdPlaying || bwdPaused) ? '⏸' : '◀';
976
+ bwdBtn.title = bwdPlaying ? 'Pause' : 'Play backward';
977
+ bwdBtn.classList.toggle('playing', bwdPlaying);
978
+ bwdBtn.classList.toggle('paused', bwdPaused);
979
+ }
980
+ }
981
+
982
+ function getActiveMarker() {
983
+ const anim = S.entry?.api.animation;
984
+ if (!anim) return null;
985
+ const frame = anim.frame;
986
+ let active = null, maxF = -1;
987
+ for (const [name, f] of Object.entries(S.markers)) {
988
+ if (f <= frame && f > maxF) { maxF = f; active = name; }
989
+ }
990
+ return active;
991
+ }
992
+
993
+ // ── Scrubber ─────────────────────────────────────────────────────────────────
994
+ function seekFrame(n) {
995
+ const anim = S.entry?.api.animation;
996
+ if (anim) { anim.seekFrame(n); S.entry.api.setAnimationPaused(true); }
997
+ el('range').value = n;
998
+ el('flbl').textContent = `${Math.round(n)} / ${S.frameCount}`;
999
+ scrollToMatchFrame(n);
1000
+ }
1001
+
1002
+ // Scroll the page so the scroll-driven position matches the seeked frame.
1003
+ // Inverts the scrollFrameFor formula from player.js:
1004
+ // t = (vh - rect.top) / (elH + vh) ↔ scrollY = absTop - vh + t*(elH+vh)
1005
+ function scrollToMatchFrame(n) {
1006
+ if (!S.markers || !S.sceneConfigs) return;
1007
+ const markerEntries = Object.entries(S.markers).sort((a, b) => a[1] - b[1]);
1008
+ for (let i = 0; i < markerEntries.length; i++) {
1009
+ const [name, fromFrame] = markerEntries[i];
1010
+ const toFrame = (markerEntries[i + 1]?.[1] ?? S.frameCount) - 1;
1011
+ if (n < fromFrame - 0.5 || n > toFrame + 0.5) continue;
1012
+ const cfg = S.sceneConfigs[name] || {};
1013
+ // Auto/pingpong scenes (e.g. an autoplay intro) are independent of scroll
1014
+ // position by design — don't drag the page around while scrubbing them.
1015
+ if (!cfg.linkedId || cfg.playback === 'auto') continue;
1016
+ const target = document.getElementById(cfg.linkedId);
1017
+ if (!target) continue;
1018
+ const t = (toFrame > fromFrame)
1019
+ ? Math.max(0, Math.min(1, (n - fromFrame) / (toFrame - fromFrame)))
1020
+ : 0;
1021
+ const vh = window.innerHeight;
1022
+ const absTop = target.getBoundingClientRect().top + window.scrollY;
1023
+ const targetY = absTop - vh + t * (target.offsetHeight + vh);
1024
+ window.scrollTo(0, Math.max(0, targetY));
1025
+ return;
1026
+ }
1027
+ }
1028
+
1029
+ // ── Parts sync ───────────────────────────────────────────────────────────────
1030
+ // Mirrors animation.js splatNameFromId.
1031
+ // Strips "hs-part." / "ctrl." prefixes and trailing Blender duplicate suffixes.
1032
+ // Preserves hierarchical names: "headphones.cup.l" → "headphones.cup.l".
1033
+ function splatNameFromId(id) {
1034
+ let s = id.replace(/^hs-part\./, '').replace(/^ctrl\./, '');
1035
+ return s.replace(/(\.\d+)+$/, '');
1036
+ }
1037
+
1038
+ // Resolves the on-disk splat file(s) for a part. If a packed "<base>.spzv"
1039
+ // exists (built with the Pack Variants tool — see examples/pack.html), it
1040
+ // is preferred: geometry is loaded once and every declared variant becomes
1041
+ // a runtime-swappable palette (Viewer#setVariant), so only the active
1042
+ // variant costs anything to render. Otherwise, parts with declared
1043
+ // color/material variants are returned as { url, variants }: only the
1044
+ // active variant's file is loaded now (each variant was independently
1045
+ // trained, so they may have different geometry/splat counts — loading all
1046
+ // of them up front would be wasteful); Viewer#setVariant fetches the
1047
+ // others lazily. Parts without variants try "<dir><splat-name>", then fall
1048
+ // back to whatever path(s) are already configured (so a renamed/suffixed
1049
+ // file — e.g. "headphones.fork.left.blue" — still resolves even when the
1050
+ // bare splat-name doesn't exist on disk).
1051
+ async function resolvePartPaths(dir, obj, lastParts) {
1052
+ const splatName = splatNameFromId(obj.id);
1053
+ const base = `${dir}${splatName}`;
1054
+
1055
+ if (obj.variants?.length) {
1056
+ if (S.diskFiles?.has(`${base}.spzv`)) return [`${base}.spzv`];
1057
+
1058
+ // Keep whichever variant is currently active (if it still exists),
1059
+ // otherwise fall back to the first declared variant.
1060
+ const existing = lastParts[obj.id];
1061
+ const prevUrl = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing.url : null;
1062
+ let activeName = obj.variants[0];
1063
+ if (prevUrl) {
1064
+ const m = prevUrl.match(/\.([^./]+)\.(?:spz|ply|splat)$/i);
1065
+ if (m && obj.variants.includes(m[1])) activeName = m[1];
1066
+ }
1067
+ const order = [activeName, ...obj.variants.filter(v => v !== activeName)];
1068
+ for (const v of order) {
1069
+ const found = await findExistingSplat(`${base}.${v}`);
1070
+ if (found) return { url: found, variants: obj.variants };
1071
+ }
1072
+ }
1073
+
1074
+ const found = await findExistingSplat(base);
1075
+ if (found) return [splitSplatExt(found).base];
1076
+
1077
+ const existing = lastParts[obj.id] ?? lastParts[splatName] ?? null;
1078
+ const existingPaths = Array.isArray(existing) ? existing : existing ? [existing] : [];
1079
+ const resolved = [];
1080
+ for (const p of existingPaths) {
1081
+ const f = await findExistingSplat(p);
1082
+ if (f) resolved.push(splitSplatExt(f).base);
1083
+ }
1084
+ return resolved.length ? resolved : [base];
1085
+ }
1086
+
1087
+ async function syncParts() {
1088
+ const anim = S.entry?.api.animation;
1089
+ if (!anim?.objects?.length) {
1090
+ setStatus('No objects in animation — load an animation first', true);
1091
+ return;
1092
+ }
1093
+ const dir = el('pd').value.trim().replace(/\/?$/, '/');
1094
+ if (!dir || dir === '/') {
1095
+ setStatus('Set a parts directory first', true);
1096
+ return;
1097
+ }
1098
+ const lastParts = S.entry?.viewer._lastParts || {};
1099
+ await refreshDiskFiles(); // explicit reload — refresh the disk listing before resolving against it
1100
+ setStatus(`Resolving ${anim.objects.length} part path(s)…`);
1101
+ const partsMap = {};
1102
+ let fileCount = 0;
1103
+ for (const obj of anim.objects) {
1104
+ const paths = await resolvePartPaths(dir, obj, lastParts);
1105
+ if (Array.isArray(paths)) {
1106
+ partsMap[obj.id] = paths.length > 1 ? paths : paths[0];
1107
+ fileCount += paths.length;
1108
+ } else {
1109
+ partsMap[obj.id] = paths; // { url, variants }
1110
+ fileCount += 1;
1111
+ }
1112
+ }
1113
+ const n = Object.keys(partsMap).length;
1114
+ setStatus(`Loading ${fileCount} splat file(s) for ${n} part(s)…`);
1115
+ try {
1116
+ await S.entry.api.loadParts(partsMap);
1117
+ } catch (e) {
1118
+ setStatus(`Sync failed: ${e.message}`, true);
1119
+ return;
1120
+ }
1121
+ renderParts();
1122
+ if (!S.apiOnline) {
1123
+ setStatus(`Loaded ${fileCount} file(s) for ${n} part(s) (API offline — not saved)`);
1124
+ return;
1125
+ }
1126
+ try {
1127
+ // This writes a static, fully-resolved `parts:` map (with variant
1128
+ // info) — the right choice for parts with declared color/material
1129
+ // variants, since player.js's dynamic partsDir-based derivation
1130
+ // (saveDirUrl, below) doesn't know about variants. partsDir takes
1131
+ // priority over this map at runtime if both are set, so don't save a
1132
+ // partsDir for pages that need this sync instead — see saveDirUrl.
1133
+ await saveParts(partsMap);
1134
+ setStatus(`Synced ${n} parts — saved to file`);
1135
+ } catch (e) {
1136
+ setStatus(`Parts loaded but save failed: ${e.message}`, true);
1137
+ }
1138
+ }
1139
+
1140
+ async function saveParts(partsMap) {
1141
+ const res = await fetch('/hs-api/js-parts', {
1142
+ method: 'POST',
1143
+ headers: { 'Content-Type': 'application/json' },
1144
+ body: JSON.stringify({ page: window.location.pathname, parts: partsMap }),
1145
+ });
1146
+ if (!res.ok) throw new Error(`/hs-api/js-parts → ${res.status}`);
1147
+ }
1148
+
1149
+ // Splat files are detected by extension fallback (.spz → .ply → .splat),
1150
+ // mirroring loadUrl() in src/viewer.js.
1151
+ const SPLAT_EXTS = ['spz', 'ply', 'splat'];
1152
+
1153
+ function splitSplatExt(path) {
1154
+ const m = path.match(/\.(spz|spzv|ply|splat)$/i);
1155
+ if (m) {
1156
+ const ext = m[1].toLowerCase();
1157
+ return { base: path.slice(0, -m[0].length), exts: [ext, ...SPLAT_EXTS.filter(e => e !== ext)] };
1158
+ }
1159
+ return { base: path, exts: SPLAT_EXTS };
1160
+ }
1161
+
1162
+ // Looks up a candidate path against the cached on-disk file listing
1163
+ // (S.diskFiles, from /hs-api/ls) — never probes the network per-file.
1164
+ // If the listing isn't available (API offline, or not fetched yet),
1165
+ // assumes nothing exists rather than guessing via HTTP requests.
1166
+ function findExistingSplat(path) {
1167
+ if (!S.diskFiles) return null;
1168
+ const { base, exts } = splitSplatExt(path);
1169
+ for (const ext of exts) {
1170
+ if (S.diskFiles.has(`${base}.${ext}`)) return `${base}.${ext}`;
1171
+ }
1172
+ return null;
1173
+ }
1174
+
1175
+ // LOD tiers live in a "<name>.lods/" folder next to the source file, as
1176
+ // "<name>.lodN.spz" — see src/device-tier.js resolveLodUrl (same lookup,
1177
+ // shared with the runtime loader) and examples/prune.html (which
1178
+ // generates them). Always .spz regardless of the source file's format.
1179
+ function lodCandidatePath(base, i) {
1180
+ const slash = base.lastIndexOf('/');
1181
+ const dir = base.slice(0, slash + 1);
1182
+ const name = base.slice(slash + 1);
1183
+ return `${dir}${name}.lods/${name}.lod${i}.spz`;
1184
+ }
1185
+
1186
+ // ── Modal (generic) ──────────────────────────────────────────────────────────
1187
+
1188
+ function closeModal() {
1189
+ document.getElementById('__hs-modal-overlay')?.remove();
1190
+ }
1191
+
1192
+ /** Opens a simple modal dialog. `buildBody(bodyEl)` populates the content. */
1193
+ function openModal(title, buildBody) {
1194
+ closeModal();
1195
+ const overlay = document.body.appendChild(document.createElement('div'));
1196
+ overlay.id = '__hs-modal-overlay';
1197
+ overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
1198
+
1199
+ const box = overlay.appendChild(document.createElement('div'));
1200
+ box.id = '__hs-modal-box';
1201
+
1202
+ const hd = box.appendChild(document.createElement('div'));
1203
+ hd.id = '__hs-modal-hd';
1204
+ const titleEl = hd.appendChild(document.createElement('span'));
1205
+ titleEl.id = '__hs-modal-title';
1206
+ titleEl.textContent = title;
1207
+ const closeBtn = hd.appendChild(document.createElement('button'));
1208
+ closeBtn.id = '__hs-modal-close';
1209
+ closeBtn.textContent = '✕';
1210
+ closeBtn.addEventListener('click', closeModal);
1211
+
1212
+ const bodyEl = box.appendChild(document.createElement('div'));
1213
+ bodyEl.id = '__hs-modal-body';
1214
+ buildBody(bodyEl);
1215
+ }
1216
+
1217
+ // ── Splat-status rows (shared between the Website row and each asset row) ───
1218
+ //
1219
+ // A "row" is { label, loaded, path }: `loaded` means the part is already
1220
+ // resolved in the live viewer (this._partIndex); `path` is the splat file
1221
+ // path to additionally probe on disk via findExistingSplat — null for
1222
+ // asset-clip rows, which don't have a separate file of their own to check
1223
+ // (a clip just animates a part the main scene already loaded), only
1224
+ // whether that part is currently loaded at all.
1225
+
1226
+ /** Renders the detailed per-row list (type/LOD badges where a path exists) into `bodyEl`, live-updating as rows resolve. */
1227
+ function renderPartRowsDetail(bodyEl, rows) {
1228
+ if (!rows.length) { bodyEl.innerHTML = '<div class="__hs-empty">No parts</div>'; return; }
1229
+ bodyEl.innerHTML = rows.map(r => {
1230
+ const typeBadge = r.path ? `<span class="__hs-pft" data-role="type"></span>` : '';
1231
+ const lodBadge = r.path ? `<span class="__hs-plod" data-role="lod" style="display:none"></span>` : '';
1232
+ return `<div class="__hs-pr">
1233
+ <span class="__hs-prn" title="${r.label}">${r.label}</span>
1234
+ ${typeBadge}
1235
+ ${lodBadge}
1236
+ <span class="__hs-pb ${r.loaded ? 'ok' : ''}" data-role="status"
1237
+ >${r.loaded ? '✓' : '…'}</span>
1238
+ </div>`;
1239
+ }).join('');
1240
+
1241
+ const rowEls = bodyEl.querySelectorAll('.__hs-pr');
1242
+ rows.forEach((r, i) => {
1243
+ const rowEl = rowEls[i];
1244
+ const badge = rowEl.querySelector('[data-role="status"]');
1245
+ if (!r.path) {
1246
+ badge.className = `__hs-pb ${r.loaded ? 'ok' : 'err'}`;
1247
+ badge.textContent = r.loaded ? '✓' : '✗';
1248
+ badge.title = r.loaded ? 'Loaded in viewer' : 'Not loaded in the current scene';
1249
+ return;
1250
+ }
1251
+ const typeBadge = rowEl.querySelector('[data-role="type"]');
1252
+ {
1253
+ const found = findExistingSplat(r.path);
1254
+ typeBadge.textContent = found ? found.slice(found.lastIndexOf('.') + 1).toLowerCase() : '?';
1255
+ typeBadge.title = found ? `Found: ${found}` : `Missing — tried ${r.path}.{spz,ply,splat}`;
1256
+ if (!r.loaded) {
1257
+ badge.className = `__hs-pb ${found ? 'ok' : 'err'}`;
1258
+ badge.textContent = found ? '✓' : '✗';
1259
+ badge.title = found ? `Found: ${found}` : `Missing — tried ${r.path}.{spz,ply,splat}`;
1260
+ } else {
1261
+ badge.title = 'Loaded in viewer';
1262
+ }
1263
+ }
1264
+ const lodBadge = rowEl.querySelector('[data-role="lod"]');
1265
+ if (lodBadge) {
1266
+ const { base: lodBase } = splitSplatExt(r.path);
1267
+ const found = [0, 1, 2, 3].map(i => {
1268
+ const candidate = lodCandidatePath(lodBase, i);
1269
+ return S.diskFiles?.has(candidate) ? candidate : null;
1270
+ });
1271
+ const files = found.filter(Boolean);
1272
+ lodBadge.classList.toggle('err', files.length === 0);
1273
+ lodBadge.textContent = files.length ? `${files.length} LOD${files.length > 1 ? 's' : ''}` : '0 LODs';
1274
+ lodBadge.title = files.length
1275
+ ? found.map((f, i) => f ? `lod${i}: ${f}` : null).filter(Boolean).join('\n')
1276
+ : `No ${lodBase.slice(lodBase.lastIndexOf('/') + 1)}.lods/ folder found next to ${lodBase}`;
1277
+ lodBadge.style.display = '';
1278
+ }
1279
+ });
1280
+ }
1281
+
1282
+ function openPartsModal(title, rows) {
1283
+ openModal(title, bodyEl => renderPartRowsDetail(bodyEl, rows));
1284
+ }
1285
+
1286
+ /** Renders the compact "<n> splats ✓ / <m> not loaded [show]" line into `container`.
1287
+ * Status reflects only what's already resolved in the live viewer (r.loaded) — no
1288
+ * disk probing here, since that ran on every render and flooded the server with
1289
+ * 404s for variants that are expected not to be loaded yet. Click "show" to
1290
+ * actually check disk via the detail modal (renderPartRowsDetail). */
1291
+ function renderSplatSummary(container, rows, modalTitle) {
1292
+ if (!container) return;
1293
+ container.innerHTML = '';
1294
+ container.style.display = '';
1295
+
1296
+ const countEl = container.appendChild(document.createElement('span'));
1297
+ countEl.className = '__hs-splat-count';
1298
+ countEl.textContent = `${rows.length} splat${rows.length === 1 ? '' : 's'}`;
1299
+
1300
+ const showBtn = container.appendChild(document.createElement('button'));
1301
+ showBtn.className = '__hs-btn';
1302
+ showBtn.textContent = 'show';
1303
+ showBtn.addEventListener('click', () => openPartsModal(modalTitle, rows));
1304
+
1305
+ if (!rows.length) return;
1306
+
1307
+ const missing = rows.filter(r => !r.loaded).length;
1308
+ const statusEl = container.appendChild(document.createElement('span'));
1309
+ if (missing === 0) {
1310
+ statusEl.className = '__hs-splat-status ok';
1311
+ statusEl.textContent = '✓';
1312
+ } else {
1313
+ statusEl.className = '__hs-splat-status';
1314
+ statusEl.textContent = `${missing} not loaded`;
1315
+ }
1316
+ }
1317
+
1318
+ // ── Mask-status rows (shown under the Main Animation row and each asset row) ─
1319
+ //
1320
+ // `maskList` is [{ name, softEdge }] — softEdge is the Blender-exported
1321
+ // default feather, overridden per-name by S.maskConfigs (see applyMaskFeather).
1322
+
1323
+ /** Renders the compact "<n> masks [Edit]" line into `container`. */
1324
+ function renderMaskSummary(container, maskList, modalTitle) {
1325
+ if (!container) return;
1326
+ container.innerHTML = '';
1327
+
1328
+ const countEl = container.appendChild(document.createElement('span'));
1329
+ countEl.className = '__hs-splat-count';
1330
+ countEl.textContent = `${maskList.length} mask${maskList.length === 1 ? '' : 's'}`;
1331
+
1332
+ const editBtn = container.appendChild(document.createElement('button'));
1333
+ editBtn.className = '__hs-btn';
1334
+ editBtn.textContent = 'Edit';
1335
+ editBtn.addEventListener('click', () => openModal(modalTitle, bodyEl => renderMasksEditBody(bodyEl, maskList)));
1336
+ }
1337
+
1338
+ /** Renders per-mask feather controls plus a master control that applies to all of them at once. */
1339
+ function renderMasksEditBody(bodyEl, maskList) {
1340
+ bodyEl.innerHTML = '';
1341
+ if (!maskList.length) {
1342
+ bodyEl.innerHTML = '<div class="__hs-empty">No mask volumes</div>';
1343
+ return;
1344
+ }
1345
+
1346
+ const wrap = bodyEl.appendChild(document.createElement('div'));
1347
+ wrap.className = '__hs-mask-edit-body';
1348
+
1349
+ const rowCtrls = [];
1350
+ const masterRow = wrap.appendChild(mkRow('All masks', mkNum(maskList[0].softEdge ?? 0.05, 0, 10, 0.01, null, v => {
1351
+ for (const m of maskList) applyMaskFeather(m.name, v);
1352
+ for (const c of rowCtrls) c.inp.value = v;
1353
+ }).el));
1354
+ masterRow.classList.add('__hs-mask-master-row');
1355
+
1356
+ wrap.appendChild(document.createElement('div')).className = '__hs-mask-divider';
1357
+
1358
+ for (const m of maskList) {
1359
+ const defaultFeather = m.softEdge ?? 0.05;
1360
+ const current = S.maskConfigs[m.name]?.feather ?? defaultFeather;
1361
+ const ctrl = mkNum(current, 0, 10, 0.01, null, v => applyMaskFeather(m.name, v));
1362
+ rowCtrls.push(ctrl);
1363
+ wrap.appendChild(mkRow(m.name, ctrl.el));
1364
+ }
1365
+ }
1366
+
1367
+ function applyMaskFeather(name, value) {
1368
+ S.maskConfigs[name] = { ...(S.maskConfigs[name] || {}), feather: value };
1369
+ S.entry?.api.setMaskFeather(name, value);
1370
+ saveMaskConfig(name, S.maskConfigs[name]);
1371
+ }
1372
+
1373
+ // Strips a trailing ".<variant>" suffix from path if it matches one of the
1374
+ // part's known variants, so per-variant paths can be rebuilt from it.
1375
+ function stripVariantSuffix(path, variants) {
1376
+ for (const v of variants) {
1377
+ if (path.endsWith(`.${v}`)) return path.slice(0, -(v.length + 1));
1378
+ }
1379
+ return path;
1380
+ }
1381
+
1382
+ // Builds one row per splat file referenced by the animation JSON — the
1383
+ // animated parts themselves, plus every declared color/material variant,
1384
+ // each as its own independent entry. See "Splat-status rows" above for
1385
+ // what `loaded`/`path` mean and how rows get checked.
1386
+ function buildAnimPartRows() {
1387
+ const anim = S.entry?.api.animation;
1388
+ if (!anim?.objects?.length) return [];
1389
+
1390
+ const partIndex = S.entry?.viewer._partIndex || {};
1391
+ const lastParts = S.entry?.viewer._lastParts || {};
1392
+
1393
+ const rows = [];
1394
+ for (const obj of anim.objects) {
1395
+ const name = splatNameFromId(obj.id);
1396
+ const rawPath = lastParts[obj.id] ?? lastParts[name] ?? null;
1397
+ const loaded = partIndex[obj.id] !== undefined || partIndex[name] !== undefined;
1398
+ const variants = obj.variants || [];
1399
+
1400
+ if (rawPath && typeof rawPath === 'object' && !Array.isArray(rawPath) && rawPath.url) {
1401
+ // Lazy variant part (resolvePartPaths returned { url, variants }):
1402
+ // only the active variant is loaded — the others are fetched on
1403
+ // demand by Viewer#setVariant, since each has its own geometry.
1404
+ const m = rawPath.url.match(/\.([^./]+)\.(?:spz|ply|splat)$/i);
1405
+ const active = m ? m[1] : '?';
1406
+ rows.push({ label: `${name} (${variants.length} variants, "${active}" active)`, loaded, path: rawPath.url });
1407
+ continue;
1408
+ }
1409
+
1410
+ const paths = Array.isArray(rawPath) ? rawPath : rawPath ? [rawPath] : [];
1411
+ if (variants.length && paths[0]?.endsWith('.spzv')) {
1412
+ // Packed variants (examples/pack.html): one file holds the shared
1413
+ // geometry plus every variant's color/alpha palette — show it as a
1414
+ // single row, swappable at runtime via Viewer#setVariant.
1415
+ rows.push({ label: `${name} (${variants.length} variants packed)`, loaded, path: paths[0] });
1416
+ } else if (variants.length) {
1417
+ // Every variant is its own real file — no separate bare-name row.
1418
+ // All variants present in `paths` are loaded simultaneously
1419
+ // (sharing this part's transform) until per-color masks select
1420
+ // between them.
1421
+ const base = paths.length ? stripVariantSuffix(paths[0], variants) : null;
1422
+ for (const v of variants) {
1423
+ const vPath = base ? `${base}.${v}` : null;
1424
+ rows.push({ label: `${name}.${v}`, loaded: loaded && paths.includes(vPath), path: vPath });
1425
+ }
1426
+ } else {
1427
+ rows.push({ label: name, loaded, path: paths[0] ?? null });
1428
+ }
1429
+ }
1430
+ return rows;
1431
+ }
1432
+
1433
+ // Builds one row per splat file this asset declares (see
1434
+ // export_holosplat_asset.py's "parts" field) — every variant of every
1435
+ // part, not just the one matching the asset's currently-selected default,
1436
+ // so the modal reflects every file on disk this asset could load, the
1437
+ // same way the Website row lists every variant of every animated object.
1438
+ // The JSON itself has no idea where the actual splat files live — that's
1439
+ // the designer's job, via the asset's own "splats path" field — so each
1440
+ // row's `path` is resolved against that, exactly like the Website row's,
1441
+ // and checked the same way (findExistingSplat, on demand in the detail
1442
+ // modal — see openPartsModal). `loaded` always reflects the live viewer
1443
+ // (regardless of whether a splats path is set), by matching this row's own
1444
+ // filename against the slots actually on the GPU right now.
1445
+ function buildAssetPartRows(asset) {
1446
+ const dir = asset.splatsDir?.trim().replace(/\/?$/, '/');
1447
+ const partIndex = S.entry?.viewer._partIndex || {};
1448
+ const fileNames = S.entry?.viewer._fileNames || [];
1449
+ const rows = [];
1450
+ for (const [id, part] of Object.entries(asset.parts ?? {})) {
1451
+ const variants = part.variants ?? [];
1452
+ const slots = partIndex[id] ?? partIndex[part.splatName];
1453
+ if (!variants.length) {
1454
+ rows.push({ label: part.splatName, loaded: slots !== undefined, path: dir ? dir + part.splatName : null });
1455
+ continue;
1456
+ }
1457
+ // Default variant loads first; the rest fetch lazily in the background
1458
+ // (see Viewer#loadClips) — check each row's own filename against the
1459
+ // slots actually on the GPU right now, rather than assuming only the
1460
+ // default is ever loaded, so this stays accurate once the background
1461
+ // fetch finishes too.
1462
+ for (const v of variants) {
1463
+ const label = `${part.splatName}.${v}`;
1464
+ const loaded = slots?.some(slot => fileNames[slot] === label) ?? false;
1465
+ rows.push({ label, loaded, path: dir ? dir + label : null });
1466
+ }
1467
+ }
1468
+ return rows;
1469
+ }
1470
+
1471
+ // Re-checks the Website row's splat status (called on every load, not just
1472
+ // after "sync", so newly-referenced or renamed splat files that are
1473
+ // missing show up here).
1474
+ function renderParts() {
1475
+ renderSplatSummary(el('website-splat-summary'), buildAnimPartRows(), 'Website splats');
1476
+ }
1477
+
1478
+ // ── Reload helpers ───────────────────────────────────────────────────────────
1479
+ async function reloadAnim() {
1480
+ const url = el('an').value.trim();
1481
+ if (!url || !S.entry) return;
1482
+ setStatus('Loading animation…');
1483
+ try {
1484
+ const anim = await S.entry.api.loadAnim(url);
1485
+ if (anim) connectAnim(anim);
1486
+ if (S.apiOnline) await saveAnimUrl(url);
1487
+ setStatus('Animation loaded');
1488
+ } catch (e) { setStatus(e.message, true); }
1489
+ }
1490
+
1491
+ async function saveAnimUrl(url) {
1492
+ try {
1493
+ await fetch('/hs-api/js-anim', {
1494
+ method: 'POST',
1495
+ headers: { 'Content-Type': 'application/json' },
1496
+ body: JSON.stringify({ page: window.location.pathname, url }),
1497
+ });
1498
+ } catch { }
1499
+ }
1500
+
1501
+ // Persists the splats directory as an explicit `partsDir: '...'` line in
1502
+ // the HTML — player.js then derives every part's file path at runtime as
1503
+ // `${partsDir}${splatNameFromId(obj.id)}${ext}` directly from the
1504
+ // animation JSON's objects list (src/player.js:602-611), no `parts:` map
1505
+ // needed. Only covers parts with no declared variants — partsDir takes
1506
+ // priority over an explicit `parts:` map at runtime, so leave this field
1507
+ // empty for scenes using "sync" (parts with color/material variants).
1508
+ async function saveDirUrl(dir) {
1509
+ try {
1510
+ await fetch('/hs-api/js-partsDir', {
1511
+ method: 'POST',
1512
+ headers: { 'Content-Type': 'application/json' },
1513
+ body: JSON.stringify({ page: window.location.pathname, partsDir: dir }),
1514
+ });
1515
+ } catch { }
1516
+ }
1517
+
1518
+ // ── Scenes ───────────────────────────────────────────────────────────────────
1519
+
1520
+ function computeScenes() {
1521
+ const sorted = Object.entries(S.markers).sort((a, b) => a[1] - b[1]);
1522
+ return sorted.map(([name, from], i) => {
1523
+ const to = (sorted[i + 1]?.[1] ?? S.frameCount) - 1;
1524
+ return { name, from, to, frames: to - from + 1 };
1525
+ });
1526
+ }
1527
+
1528
+ // All page elements with IDs that aren't part of the editor UI.
1529
+ function scanPageEls() {
1530
+ return Array.from(document.querySelectorAll('[id]'))
1531
+ .filter(e => !e.id.startsWith('__hs-') && e.id)
1532
+ .map(e => ({ id: e.id, tag: e.tagName.toLowerCase() }));
1533
+ }
1534
+
1535
+ // ── Blend-zone visualizer ─────────────────────────────────────────────────────
1536
+ // Transient overlay on the live page's linked scene container, showing the
1537
+ // blend in/out percentages as light-blue bands at its top/bottom. Not a
1538
+ // toggle — shown only while a blend value is actively being dragged/typed
1539
+ // (see mkNum's onActive), hidden the instant that stops.
1540
+ function showBlendOverlay(linkedEl, blendInPct, blendOutPct) {
1541
+ if (!linkedEl) return;
1542
+ let ov = document.getElementById('__hs-blend-overlay');
1543
+ if (!ov) {
1544
+ ov = document.createElement('div');
1545
+ ov.id = '__hs-blend-overlay';
1546
+ ov.appendChild(document.createElement('div')).className = '__hs-blend-zone __hs-blend-zone-top';
1547
+ ov.appendChild(document.createElement('div')).className = '__hs-blend-zone __hs-blend-zone-bot';
1548
+ document.body.appendChild(ov);
1549
+ }
1550
+ const rect = linkedEl.getBoundingClientRect();
1551
+ ov.style.left = rect.left + 'px';
1552
+ ov.style.top = rect.top + 'px';
1553
+ ov.style.width = rect.width + 'px';
1554
+ ov.style.height = rect.height + 'px';
1555
+ ov.style.display = 'block';
1556
+ ov.children[0].style.height = Math.max(0, Math.min(100, blendInPct)) + '%';
1557
+ ov.children[1].style.height = Math.max(0, Math.min(100, blendOutPct)) + '%';
1558
+ }
1559
+ function hideBlendOverlay() {
1560
+ const ov = document.getElementById('__hs-blend-overlay');
1561
+ if (ov) ov.style.display = 'none';
1562
+ }
1563
+
1564
+ // ── Shared attribute-row controls (used by scene + mask cards) ───────────────
1565
+
1566
+ function mkToggle(checked, onChange) {
1567
+ const lbl = document.createElement('label');
1568
+ lbl.className = '__hs-toggle';
1569
+ const cb = document.createElement('input');
1570
+ cb.type = 'checkbox'; cb.checked = checked;
1571
+ const track = document.createElement('span'); track.className = '__hs-toggle-track';
1572
+ const thumb = document.createElement('span'); thumb.className = '__hs-toggle-thumb';
1573
+ lbl.appendChild(cb); lbl.appendChild(track); lbl.appendChild(thumb);
1574
+ cb.addEventListener('change', () => onChange(cb.checked));
1575
+ return { el: lbl, cb };
1576
+ }
1577
+
1578
+ function mkRow(label, ctrl) {
1579
+ const r = document.createElement('div'); r.className = '__hs-attr-row';
1580
+ const l = document.createElement('span'); l.className = '__hs-attr-lbl'; l.textContent = label;
1581
+ r.appendChild(l); r.appendChild(ctrl);
1582
+ return r;
1583
+ }
1584
+
1585
+ // onActive(bool), if given, fires while the value is being actively
1586
+ // adjusted — dragging or focused for typing — and is meant for transient
1587
+ // UI feedback (e.g. the blend-zone visualizer), not persisted state.
1588
+ function mkNum(val, min, max, step, unit, onChange, onActive) {
1589
+ const wrap = document.createElement('div'); wrap.className = '__hs-num-wrap';
1590
+ const inp = document.createElement('input');
1591
+ inp.type = 'number'; inp.className = '__hs-ninp';
1592
+ inp.min = min; inp.max = max; inp.step = step; inp.value = val;
1593
+ inp.addEventListener('change', () => {
1594
+ const v = parseFloat(inp.value);
1595
+ const clamped = isNaN(v) ? min : Math.max(min, Math.min(max, v));
1596
+ inp.value = clamped;
1597
+ onChange(clamped);
1598
+ });
1599
+ if (onActive) {
1600
+ inp.addEventListener('focus', () => onActive(true));
1601
+ inp.addEventListener('blur', () => onActive(false));
1602
+ }
1603
+
1604
+ // Drag-slider: horizontal drag changes value; short click = focus for typing
1605
+ let drag = null;
1606
+ inp.addEventListener('pointerdown', e => {
1607
+ if (e.button !== 0 || inp === document.activeElement) return;
1608
+ drag = { x: e.clientX, val: parseFloat(inp.value) || 0, moved: false };
1609
+ inp.setPointerCapture(e.pointerId);
1610
+ inp.classList.add('dragging');
1611
+ onActive?.(true);
1612
+ e.preventDefault();
1613
+ });
1614
+ inp.addEventListener('pointermove', e => {
1615
+ if (!drag) return;
1616
+ const dx = e.clientX - drag.x;
1617
+ if (Math.abs(dx) > 3) drag.moved = true;
1618
+ if (drag.moved) {
1619
+ const newVal = Math.max(min, Math.min(max, +(drag.val + dx * step).toFixed(10)));
1620
+ inp.value = newVal;
1621
+ onChange(newVal);
1622
+ }
1623
+ });
1624
+ inp.addEventListener('pointerup', e => {
1625
+ if (!drag) return;
1626
+ const moved = drag.moved;
1627
+ drag = null;
1628
+ inp.classList.remove('dragging');
1629
+ inp.releasePointerCapture(e.pointerId);
1630
+ if (!moved) { inp.focus(); inp.select(); } // 'focus' listener takes over onActive
1631
+ else onActive?.(false); // drag gesture ended
1632
+ });
1633
+
1634
+ wrap.appendChild(inp);
1635
+ if (unit) {
1636
+ const u = document.createElement('span'); u.className = '__hs-deg-unit'; u.textContent = unit;
1637
+ wrap.appendChild(u);
1638
+ }
1639
+ return { el: wrap, inp };
1640
+ }
1641
+
1642
+ // NOTE: orbit/follow-mouse config (damping, resetSpeed, resetEase, h/v
1643
+ // limits) was removed here as part of a deliberate cleanup — it will be
1644
+ // re-added slowly later.
1645
+ //
1646
+ // blendIn/blendOut replace the old scrollTop/scrollBot fields (removed
1647
+ // because they were editable here but never read by player.js's runtime).
1648
+ // These ARE read — see setupScrollPlayback's updateSceneBlend in player.js.
1649
+ function defaultConfig() {
1650
+ return {
1651
+ linkedId: '',
1652
+ playback: 'scroll',
1653
+ waitForTimeline: false,
1654
+ pingpong: false,
1655
+ playOnce: false,
1656
+ blendIn: 0, // % of this scene's own container height, eased in from the previous scene's frame
1657
+ blendOut: 0, // % of this scene's own container height, eased out toward the next scene's frame
1658
+ pan: { enabled: false, damping: 0, button: 'right', limited: false, radius: 500 },
1659
+ zoom: { enabled: false, limited: false, range: 500 },
1660
+ gyro: false,
1661
+ };
1662
+ }
1663
+
1664
+ // Merge sparse raw config with defaults, producing a complete config object.
1665
+ function mergeWithDefault(raw) {
1666
+ const def = defaultConfig();
1667
+ const r = raw || {};
1668
+ return { ...def, ...r,
1669
+ pan: { ...def.pan, ...(r.pan || {}) },
1670
+ zoom: { ...def.zoom, ...(r.zoom || {}) },
1671
+ };
1672
+ }
1673
+
1674
+ // Strip keys that equal their default values so saved JSON is minimal.
1675
+ // Disabled blocks are omitted entirely; enabled blocks keep only non-default fields.
1676
+ // Missing key = false/default when read back via mergeWithDefault.
1677
+ function sparsify(cfg) {
1678
+ const def = defaultConfig();
1679
+ const out = {};
1680
+ if (cfg.linkedId !== def.linkedId) out.linkedId = cfg.linkedId;
1681
+ if (cfg.playback !== def.playback) out.playback = cfg.playback;
1682
+ if (cfg.waitForTimeline !== def.waitForTimeline) out.waitForTimeline = cfg.waitForTimeline;
1683
+ if (cfg.pingpong !== def.pingpong) out.pingpong = cfg.pingpong;
1684
+ if (cfg.playOnce !== def.playOnce) out.playOnce = cfg.playOnce;
1685
+ if (cfg.blendIn !== def.blendIn) out.blendIn = cfg.blendIn;
1686
+ if (cfg.blendOut !== def.blendOut) out.blendOut = cfg.blendOut;
1687
+ if (cfg.gyro !== def.gyro) out.gyro = cfg.gyro;
1688
+ for (const key of ['pan', 'zoom']) {
1689
+ const blk = cfg[key] || {};
1690
+ const dblk = def[key] || {};
1691
+ if (!blk.enabled) continue; // disabled → omit entire block
1692
+ const sparse = { enabled: true };
1693
+ for (const [k, v] of Object.entries(blk)) {
1694
+ if (k !== 'enabled' && v !== dblk[k]) sparse[k] = v;
1695
+ }
1696
+ out[key] = sparse;
1697
+ }
1698
+ return out;
1699
+ }
1700
+
1701
+ function hasActiveConfig(cfg) {
1702
+ return !!(cfg.pan?.enabled || cfg.zoom?.enabled || cfg.pingpong || cfg.playOnce || cfg.gyro
1703
+ || cfg.blendIn > 0 || cfg.blendOut > 0);
1704
+ }
1705
+
1706
+ // Fetch the HTML source file and read config from JS sentinels.
1707
+ // Ensures the editor reflects any saves that happened in a previous session.
1708
+ async function loadPageState() {
1709
+ // ── HTML source read (requires API) ────────────────────────────────────────
1710
+ if (S.apiOnline) {
1711
+ try {
1712
+ const rel = window.location.pathname.replace(/^\//, '').split('?')[0] || 'index.html';
1713
+ const res = await fetch(`/hs-api/file?path=${encodeURIComponent(rel)}`);
1714
+ if (res.ok) {
1715
+ const html = await res.text();
1716
+ const doc = new DOMParser().parseFromString(html, 'text/html');
1717
+
1718
+ function mergeScenes(parsed) {
1719
+ for (const [name, raw] of Object.entries(parsed)) {
1720
+ S.sceneConfigs[name] = mergeWithDefault(raw);
1721
+ }
1722
+ }
1723
+
1724
+ // Read all config from JS source sentinels
1725
+ for (const scriptEl of doc.querySelectorAll('script')) {
1726
+ const src = scriptEl.textContent;
1727
+ const mScenes = src.match(/^[ \t]*scenes\s*:\s*(\{.+\}),?\s*\/\/\s*hs-scenes\s*$/m);
1728
+ if (mScenes) { try { mergeScenes(JSON.parse(mScenes[1])); } catch { } }
1729
+ const mMasks = src.match(/^[ \t]*masks\s*:\s*(\{.+\}),?\s*\/\/\s*hs-masks\s*$/m);
1730
+ if (mMasks) { try {
1731
+ for (const [name, raw] of Object.entries(JSON.parse(mMasks[1]))) S.maskConfigs[name] = { ...raw };
1732
+ } catch { } }
1733
+ const mClips = src.match(/^[ \t]*clips\s*:\s*(\[.*\]),?\s*\/\/\s*hs-clips\s*$/m);
1734
+ if (mClips && !S.assets.length) { try {
1735
+ // Each entry is either a bare url string, or {url, splatsDir,
1736
+ // defaults} once a splats path and/or per-axis default
1737
+ // variant has been set — see saveAssetsAttr.
1738
+ S.assets = JSON.parse(mClips[1]).map(entry => {
1739
+ const obj = typeof entry === 'string' ? { url: entry } : entry;
1740
+ return {
1741
+ url: obj.url ?? '', splatsDir: obj.splatsDir ?? '', defaults: obj.defaults ?? {},
1742
+ status: 'idle', clipIds: [], axes: {}, states: {}, parts: {}, masks: [],
1743
+ };
1744
+ });
1745
+ } catch { } }
1746
+ const mSh = src.match(/^[ \t]*sh\s*:\s*(\d+)[^\n]*\/\/\s*hs-sh\s*$/m);
1747
+ if (mSh) S.globalSh = +mSh[1];
1748
+ const mZi = src.match(/^[ \t]*zIndex\s*:\s*(-?\d+)[^\n]*\/\/\s*hs-zi\s*$/m);
1749
+ if (mZi) { S.globalZIndex = +mZi[1]; if (S.entry?.root) S.entry.root.style.zIndex = mZi[1]; }
1750
+ const mAa = src.match(/^[ \t]*aaDilation\s*:\s*([0-9.]+)[^\n]*\/\/\s*hs-aa\s*$/m);
1751
+ if (mAa) { S.globalAaDilation = +mAa[1]; S.entry?.api?.setAaDilation?.(S.globalAaDilation); }
1752
+ const mAnim = src.match(/^\s*animation\s*:\s*(['"])(.*?)\1/m);
1753
+ if (mAnim && !el('an').value) el('an').value = mAnim[2];
1754
+ const mPartsDir = src.match(/^\s*partsDir\s*:\s*(['"])(.*?)\1/m);
1755
+ if (mPartsDir && !el('pd').value) { el('pd').value = mPartsDir[2]; S.lastSavedDir = mPartsDir[2]; }
1756
+ // Derive partsDir from saved compact parts line (// hs-parts sentinel)
1757
+ if (!el('pd').value) {
1758
+ const mHsParts = src.match(/^[ \t]*parts\s*:\s*(\{.+\}),?\s*\/\/\s*hs-parts\s*$/m);
1759
+ if (mHsParts) {
1760
+ try {
1761
+ const map = JSON.parse(mHsParts[1]);
1762
+ const firstVal = Object.values(map)[0] || '';
1763
+ const lastSlash = firstVal.lastIndexOf('/');
1764
+ if (lastSlash >= 0) el('pd').value = firstVal.slice(0, lastSlash + 1);
1765
+ } catch { }
1766
+ }
1767
+ }
1768
+ // Derive partsDir from existing multi-line parts block (first path value)
1769
+ if (!el('pd').value) {
1770
+ const mBlock = src.match(/parts\s*:\s*\{([\s\S]+?)\}/);
1771
+ if (mBlock) {
1772
+ const firstPath = mBlock[1].match(/['"]([^'"]+\.(?:spz|ply|splat))['"]/)?.[1];
1773
+ if (firstPath) {
1774
+ const lastSlash = firstPath.lastIndexOf('/');
1775
+ if (lastSlash >= 0) el('pd').value = firstPath.slice(0, lastSlash + 1);
1776
+ }
1777
+ }
1778
+ }
1779
+ }
1780
+ window.__hsSceneConfigs = window.__hsSceneConfigs || {};
1781
+ Object.assign(window.__hsSceneConfigs, S.sceneConfigs);
1782
+ window.__hsMaskConfigs = window.__hsMaskConfigs || {};
1783
+ Object.assign(window.__hsMaskConfigs, S.maskConfigs);
1784
+ }
1785
+ } catch { }
1786
+ }
1787
+ renderGlobalControls();
1788
+
1789
+ // ── Animation markers (direct fetch, no API needed) ────────────────────────
1790
+ if (S.frameCount === 0) {
1791
+ const animUrl = el('an').value.trim();
1792
+ if (animUrl) {
1793
+ try {
1794
+ const r = await fetch(animUrl);
1795
+ if (!r.ok) throw new Error(r.status);
1796
+ const data = await r.json();
1797
+ const raw = data.markers;
1798
+ S.markers = Array.isArray(raw)
1799
+ ? Object.fromEntries(raw.map(m => [m.name, m.frame]))
1800
+ : (raw || {});
1801
+ S.frameCount = data.frameCount || 0;
1802
+ el('range').max = Math.max(0, S.frameCount - 1);
1803
+ el('flbl').textContent = `0 / ${S.frameCount}`;
1804
+ setStatus(`${S.frameCount} frames · ${Object.keys(S.markers).length} markers`);
1805
+ } catch { /* animation file unreachable — wait for live player */ }
1806
+ }
1807
+ }
1808
+ }
1809
+
1810
+ function debouncedSaveScenesAttr() {
1811
+ clearTimeout(_saveTimer);
1812
+ _saveTimer = setTimeout(saveScenesAttr, 300);
1813
+ }
1814
+
1815
+ async function saveScenesAttr() {
1816
+ // Mirror full configs to global so viewer _syncCameraMode picks them up
1817
+ window.__hsSceneConfigs = window.__hsSceneConfigs || {};
1818
+ Object.assign(window.__hsSceneConfigs, S.sceneConfigs);
1819
+
1820
+ if (!S.apiOnline) return;
1821
+
1822
+ // Build sparse version for storage — omit keys matching defaults
1823
+ const sparse = {};
1824
+ for (const [name, cfg] of Object.entries(S.sceneConfigs)) sparse[name] = sparsify(cfg);
1825
+
1826
+ try {
1827
+ await fetch('/hs-api/js-scenes', {
1828
+ method: 'POST',
1829
+ headers: { 'Content-Type': 'application/json' },
1830
+ body: JSON.stringify({ page: window.location.pathname, scenes: sparse }),
1831
+ });
1832
+ } catch { }
1833
+ }
1834
+
1835
+ function saveConfig(name, config) {
1836
+ S.sceneConfigs[name] = config;
1837
+ (window.__hsSceneConfigs = window.__hsSceneConfigs || {})[name] = config;
1838
+ debouncedSaveScenesAttr();
1839
+ }
1840
+
1841
+ function debouncedSaveMasksAttr() {
1842
+ clearTimeout(_maskSaveTimer);
1843
+ _maskSaveTimer = setTimeout(saveMasksAttr, 300);
1844
+ }
1845
+
1846
+ async function saveMasksAttr() {
1847
+ // Mirror full configs to global so the player picks up live feather overrides
1848
+ window.__hsMaskConfigs = window.__hsMaskConfigs || {};
1849
+ Object.assign(window.__hsMaskConfigs, S.maskConfigs);
1850
+
1851
+ if (!S.apiOnline) return;
1852
+
1853
+ // Build sparse version for storage — omit feather values matching the
1854
+ // volume's exported default so saved JSON is minimal. Covers both the
1855
+ // main animation's volumes and every asset's clip/transition masks.
1856
+ const vols = [
1857
+ ...(S.entry?.viewer?._animation?.volumes ?? []),
1858
+ ...S.assets.flatMap(a => a.masks ?? []),
1859
+ ];
1860
+ const sparse = {};
1861
+ for (const vol of vols) {
1862
+ const cfg = S.maskConfigs[vol.name];
1863
+ if (cfg && cfg.feather != null && cfg.feather !== (vol.softEdge ?? 0.05)) {
1864
+ sparse[vol.name] = { feather: cfg.feather };
1865
+ }
1866
+ }
1867
+
1868
+ try {
1869
+ await fetch('/hs-api/js-masks', {
1870
+ method: 'POST',
1871
+ headers: { 'Content-Type': 'application/json' },
1872
+ body: JSON.stringify({ page: window.location.pathname, masks: sparse }),
1873
+ });
1874
+ } catch { }
1875
+ }
1876
+
1877
+ function saveMaskConfig(name, config) {
1878
+ S.maskConfigs[name] = config;
1879
+ (window.__hsMaskConfigs = window.__hsMaskConfigs || {})[name] = config;
1880
+ debouncedSaveMasksAttr();
1881
+ }
1882
+
1883
+ const SH_LABELS = ['0 — dc only', '1', '2', '3 — full'];
1884
+
1885
+ function renderGlobalControls() {
1886
+ const wrap = el('global-cfg');
1887
+ if (!wrap) return;
1888
+ wrap.innerHTML = '';
1889
+
1890
+ // SH degree — custom dropdown (avoids overflow:hidden clipping on panel)
1891
+ const row1 = wrap.appendChild(document.createElement('div'));
1892
+ row1.className = '__hs-attr-row';
1893
+ const lbl1 = row1.appendChild(document.createElement('span'));
1894
+ lbl1.className = '__hs-attr-lbl'; lbl1.textContent = 'SH degree';
1895
+ const dropBtn = document.createElement('button');
1896
+ dropBtn.className = '__hs-drop-btn';
1897
+ dropBtn.id = '__hs-sh-sel';
1898
+ dropBtn.textContent = SH_LABELS[S.globalSh] ?? S.globalSh;
1899
+ row1.appendChild(dropBtn);
1900
+
1901
+ let shMenu = null;
1902
+ const closeShMenu = () => { if (shMenu) { shMenu.remove(); shMenu = null; } };
1903
+ dropBtn.addEventListener('click', e => {
1904
+ e.stopPropagation();
1905
+ if (shMenu) { closeShMenu(); return; }
1906
+ const rect = dropBtn.getBoundingClientRect();
1907
+ const menu = document.createElement('div');
1908
+ menu.className = '__hs-drop-menu';
1909
+ menu.style.cssText = `left:${rect.left}px;top:${rect.bottom + 2}px;min-width:${rect.width}px;`;
1910
+ shMenu = menu;
1911
+ for (let i = 0; i < SH_LABELS.length; i++) {
1912
+ const item = document.createElement('div');
1913
+ item.className = '__hs-drop-item' + (i === S.globalSh ? ' active' : '');
1914
+ item.textContent = SH_LABELS[i];
1915
+ item.addEventListener('click', () => {
1916
+ S.globalSh = i;
1917
+ dropBtn.textContent = SH_LABELS[i];
1918
+ S.entry?.api?.setShDegree?.(i);
1919
+ saveGlobalSh();
1920
+ closeShMenu();
1921
+ });
1922
+ menu.appendChild(item);
1923
+ }
1924
+ document.body.appendChild(menu);
1925
+ setTimeout(() => document.addEventListener('click', closeShMenu, { once: true }), 0);
1926
+ });
1927
+
1928
+ // z-index
1929
+ const row2 = wrap.appendChild(document.createElement('div'));
1930
+ row2.className = '__hs-attr-row';
1931
+ const lbl2 = row2.appendChild(document.createElement('span'));
1932
+ lbl2.className = '__hs-attr-lbl'; lbl2.textContent = 'z-index';
1933
+ const ziInp = document.createElement('input');
1934
+ ziInp.type = 'number'; ziInp.className = '__hs-ninp'; ziInp.style.width = '70px';
1935
+ ziInp.value = S.globalZIndex;
1936
+ ziInp.addEventListener('change', () => {
1937
+ const v = parseInt(ziInp.value) || 0;
1938
+ ziInp.value = v;
1939
+ S.globalZIndex = v;
1940
+ if (S.entry?.root) S.entry.root.style.zIndex = String(v);
1941
+ saveGlobalZIndex();
1942
+ });
1943
+ row2.appendChild(ziInp);
1944
+
1945
+ // AA dilation
1946
+ wrap.appendChild(mkRow('AA dilation', mkNum(S.globalAaDilation, 0, 0.5, 0.01, null, v => {
1947
+ S.globalAaDilation = v;
1948
+ S.entry?.viewer?.setAaDilation?.(v);
1949
+ clearTimeout(_aaSaveTimer);
1950
+ _aaSaveTimer = setTimeout(() => saveGlobalAaDilation(), 400);
1951
+ }).el));
1952
+ }
1953
+
1954
+ async function saveGlobalSh() {
1955
+ if (!S.apiOnline) return;
1956
+ try {
1957
+ await fetch('/hs-api/js-sh', {
1958
+ method: 'POST',
1959
+ headers: { 'Content-Type': 'application/json' },
1960
+ body: JSON.stringify({ page: window.location.pathname, sh: S.globalSh }),
1961
+ });
1962
+ } catch { }
1963
+ }
1964
+
1965
+ async function saveGlobalZIndex() {
1966
+ if (!S.apiOnline) return;
1967
+ try {
1968
+ await fetch('/hs-api/js-zIndex', {
1969
+ method: 'POST',
1970
+ headers: { 'Content-Type': 'application/json' },
1971
+ body: JSON.stringify({ page: window.location.pathname, zIndex: S.globalZIndex }),
1972
+ });
1973
+ } catch { }
1974
+ }
1975
+
1976
+ async function saveGlobalAaDilation() {
1977
+ if (!S.apiOnline) return;
1978
+ try {
1979
+ await fetch('/hs-api/js-aaDilation', {
1980
+ method: 'POST',
1981
+ headers: { 'Content-Type': 'application/json' },
1982
+ body: JSON.stringify({ page: window.location.pathname, aaDilation: S.globalAaDilation }),
1983
+ });
1984
+ } catch { }
1985
+ }
1986
+
1987
+ // ── Asset clip files (product customization — see export_holosplat_clips.py) ─
1988
+
1989
+ function assetNameLabel(asset) {
1990
+ if (asset.status === 'loading') return 'loading…';
1991
+ if (asset.status === 'error') return 'error';
1992
+ if (!asset.clipIds?.length) return '—';
1993
+ return asset.clipIds.join(', ');
1994
+ }
1995
+
1996
+ function renderAssetsList() {
1997
+ const list = el('assets-list');
1998
+ if (!list) return;
1999
+ list.innerHTML = '';
2000
+ S.assets.forEach((asset, i) => {
2001
+ const row = list.appendChild(document.createElement('div'));
2002
+ row.className = '__hs-fr __hs-asset-row';
2003
+
2004
+ const idx = row.appendChild(document.createElement('span'));
2005
+ idx.className = '__hs-asset-idx';
2006
+ idx.textContent = `Asset ${i}`;
2007
+
2008
+ const nameEl = row.appendChild(document.createElement('span'));
2009
+ nameEl.className = '__hs-asset-name' + (asset.status === 'error' ? ' err' : asset.status === 'loading' ? ' pending' : '');
2010
+ nameEl.textContent = assetNameLabel(asset);
2011
+ nameEl.title = asset.clipIds?.join('\n') ?? '';
2012
+
2013
+ const inp = row.appendChild(document.createElement('input'));
2014
+ inp.placeholder = '/scenes/headphones.json';
2015
+ inp.spellcheck = false;
2016
+ inp.value = asset.url ?? '';
2017
+ inp.addEventListener('blur', () => {
2018
+ const v = inp.value.trim();
2019
+ if (v === asset.url) return;
2020
+ asset.url = v;
2021
+ debouncedSaveAssetsAttr();
2022
+ if (v) loadAsset(i);
2023
+ });
2024
+ inp.addEventListener('keydown', e => { if (e.key === 'Enter') inp.blur(); });
2025
+
2026
+ const reloadBtn = row.appendChild(document.createElement('button'));
2027
+ reloadBtn.className = '__hs-btn';
2028
+ reloadBtn.title = 'Reload';
2029
+ reloadBtn.textContent = '↺';
2030
+ reloadBtn.addEventListener('click', () => { refreshDiskFiles(); loadAsset(i); });
2031
+
2032
+ const removeBtn = row.appendChild(document.createElement('button'));
2033
+ removeBtn.className = '__hs-btn __hs-asset-remove';
2034
+ removeBtn.title = 'Remove';
2035
+ removeBtn.textContent = '✕';
2036
+ removeBtn.addEventListener('click', () => removeAsset(i));
2037
+
2038
+ // Row 2: where this asset's referenced splat files actually live —
2039
+ // the clip JSON itself has no idea, so the designer points it here.
2040
+ const splatRow = list.appendChild(document.createElement('div'));
2041
+ splatRow.className = '__hs-splat-row';
2042
+
2043
+ const dirInp = splatRow.appendChild(document.createElement('input'));
2044
+ dirInp.placeholder = '/scenes/headphones/';
2045
+ dirInp.spellcheck = false;
2046
+ dirInp.value = asset.splatsDir ?? '';
2047
+ dirInp.title = "Directory where this asset's referenced splat files live";
2048
+ dirInp.addEventListener('blur', () => {
2049
+ const v = dirInp.value.trim();
2050
+ if (v === asset.splatsDir) return;
2051
+ asset.splatsDir = v;
2052
+ debouncedSaveAssetsAttr();
2053
+ refreshDiskFiles();
2054
+ loadAsset(i);
2055
+ });
2056
+ dirInp.addEventListener('keydown', e => { if (e.key === 'Enter') dirInp.blur(); });
2057
+
2058
+ const summaryEl = splatRow.appendChild(document.createElement('span'));
2059
+ summaryEl.className = '__hs-splat-summary';
2060
+ renderSplatSummary(summaryEl, buildAssetPartRows(asset), `Asset ${i} splats`);
2061
+
2062
+ // Row 2b: masks declared by this asset's clips/transitions, with a
2063
+ // feather-edit modal (see renderMaskSummary/openMasksModal).
2064
+ const maskRow = list.appendChild(document.createElement('div'));
2065
+ maskRow.className = '__hs-splat-row';
2066
+ const maskLbl = maskRow.appendChild(document.createElement('span'));
2067
+ maskLbl.className = '__hs-fl'; maskLbl.style.width = 'auto'; maskLbl.textContent = 'Masks';
2068
+ const maskSummaryEl = maskRow.appendChild(document.createElement('span'));
2069
+ maskSummaryEl.className = '__hs-splat-summary';
2070
+ renderMaskSummary(maskSummaryEl, asset.masks ?? [], `Asset ${i} masks`);
2071
+
2072
+ // Row 3: one dropdown per variant axis discovered in the asset JSON
2073
+ // (see export_holosplat_asset.py), picking that axis's default value.
2074
+ // How switching values actually animates anything isn't implemented
2075
+ // yet — this just records which value loads by default.
2076
+ const axisNames = Object.keys(asset.axes ?? {});
2077
+ if (axisNames.length) {
2078
+ const axisRow = list.appendChild(document.createElement('div'));
2079
+ axisRow.className = '__hs-fr';
2080
+ for (const axis of axisNames) {
2081
+ const lbl = axisRow.appendChild(document.createElement('span'));
2082
+ lbl.className = '__hs-fl'; lbl.textContent = axis;
2083
+ const sel = axisRow.appendChild(document.createElement('select'));
2084
+ sel.className = '__hs-el-sel'; sel.style.width = 'auto';
2085
+ for (const value of asset.axes[axis]) {
2086
+ const o = document.createElement('option');
2087
+ o.value = value; o.textContent = value;
2088
+ if (asset.defaults?.[axis] === value) o.selected = true;
2089
+ sel.appendChild(o);
2090
+ }
2091
+ sel.addEventListener('change', () => {
2092
+ asset.defaults = { ...(asset.defaults ?? {}), [axis]: sel.value };
2093
+ debouncedSaveAssetsAttr();
2094
+ loadAsset(i);
2095
+ });
2096
+ }
2097
+ }
2098
+
2099
+ // Row 4: one dropdown per state axis discovered in the asset JSON
2100
+ // (see export_holosplat_asset.py's "state: <axis>=<value>" markers),
2101
+ // picking that axis's default state — same defaults map as the axis
2102
+ // row above (Viewer applies whichever one matches via loadClips()).
2103
+ const stateAxisNames = Object.keys(asset.states ?? {});
2104
+ if (stateAxisNames.length) {
2105
+ const stateRow = list.appendChild(document.createElement('div'));
2106
+ stateRow.className = '__hs-fr';
2107
+ for (const axis of stateAxisNames) {
2108
+ const lbl = stateRow.appendChild(document.createElement('span'));
2109
+ lbl.className = '__hs-fl'; lbl.textContent = `state: ${axis}`;
2110
+ const sel = stateRow.appendChild(document.createElement('select'));
2111
+ sel.className = '__hs-el-sel'; sel.style.width = 'auto';
2112
+ for (const value of asset.states[axis]) {
2113
+ const o = document.createElement('option');
2114
+ o.value = value; o.textContent = value;
2115
+ if (asset.defaults?.[axis] === value) o.selected = true;
2116
+ sel.appendChild(o);
2117
+ }
2118
+ sel.addEventListener('change', () => {
2119
+ asset.defaults = { ...(asset.defaults ?? {}), [axis]: sel.value };
2120
+ debouncedSaveAssetsAttr();
2121
+ loadAsset(i);
2122
+ });
2123
+ }
2124
+ }
2125
+ });
2126
+ }
2127
+
2128
+ async function loadAsset(i, { skipIfLoaded = false } = {}) {
2129
+ const asset = S.assets[i];
2130
+ if (!asset?.url || !S.entry) return;
2131
+ // Unload whatever this slot previously contributed before reloading, so
2132
+ // editing/reloading a URL doesn't leave stale clips registered under it.
2133
+ // Skipped when skipIfLoaded resolves to a no-op reload below (nothing to
2134
+ // unload yet in that case anyway, since clipIds is still empty on connect).
2135
+ if (asset.clipIds?.length) S.entry.api.unloadClips(asset.clipIds);
2136
+ asset.status = 'loading';
2137
+ asset.clipIds = [];
2138
+ // Deliberately leave asset.axes/parts alone here — clearing them would
2139
+ // collapse the axis dropdowns and splat-summary row for the duration of
2140
+ // the fetch, then snap them back once it resolves. Keep showing the
2141
+ // previous data until the new data is ready to replace it.
2142
+ renderAssetsList();
2143
+ try {
2144
+ // Fetch the JSON directly first — loadClips alone only returns clip
2145
+ // ids, but we need axes/parts/defaults resolved before asking the
2146
+ // player to load it, so the asset's geometry loads on the first try.
2147
+ const res = await fetch(asset.url);
2148
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
2149
+ const data = await res.json();
2150
+ asset.axes = data.axes ?? {};
2151
+ asset.parts = data.parts ?? {};
2152
+ // State axes (see export_holosplat_asset.py's "state: <axis>=<value>"
2153
+ // markers) — value list per axis, used by the default-state dropdown
2154
+ // above. Falls back to each axis's own exported default below.
2155
+ asset.states = Object.fromEntries(
2156
+ Object.entries(data.states ?? {}).map(([axis, s]) => [axis, Object.keys(s.markers ?? {})])
2157
+ );
2158
+
2159
+ // Mask volumes this asset's clips/transitions declare — deduped by
2160
+ // name, since the same volume can recur across multiple clips.
2161
+ const maskMap = {};
2162
+ for (const c of data.clips ?? []) for (const m of c.masks ?? []) maskMap[m.name] = m.softEdge ?? 0.05;
2163
+ for (const t of Object.values(data.transitions ?? {})) for (const m of t.masks ?? []) maskMap[m.name] = m.softEdge ?? 0.05;
2164
+ for (const s of Object.values(data.states ?? {})) for (const m of s.masks ?? []) maskMap[m.name] = m.softEdge ?? 0.05;
2165
+ asset.masks = Object.entries(maskMap).map(([name, softEdge]) => ({ name, softEdge }));
2166
+
2167
+ // Drop defaults for axes that no longer exist; fill in a first value
2168
+ // for any new axis so every axis always has a default once loaded —
2169
+ // for state axes, prefer the axis's own exported default value over
2170
+ // an arbitrary first one.
2171
+ const defaults = {};
2172
+ for (const [axis, values] of Object.entries(asset.axes)) {
2173
+ defaults[axis] = (asset.defaults?.[axis] && values.includes(asset.defaults[axis]))
2174
+ ? asset.defaults[axis] : values[0];
2175
+ }
2176
+ for (const [axis, values] of Object.entries(asset.states)) {
2177
+ if (asset.defaults?.[axis] && values.includes(asset.defaults[axis])) {
2178
+ defaults[axis] = asset.defaults[axis];
2179
+ } else {
2180
+ defaults[axis] = data.states[axis]?.default ?? values[0];
2181
+ }
2182
+ }
2183
+ asset.defaults = defaults;
2184
+
2185
+ // The player's own boot sequence already loads every `clips:` entry
2186
+ // (see player.js), so the editor's automatic on-connect pass
2187
+ // (loadAllAssets, skipIfLoaded:true) would otherwise reload the exact
2188
+ // same asset a second time — duplicating every variant fetch (extra
2189
+ // 404s) and, worse, briefly replacing the already-fully-loaded
2190
+ // multi-variant scene with the single-default-variant one mid-scroll,
2191
+ // which is what caused the flicker/stutter. Detect that case by
2192
+ // checking whether everything this asset declares (parts, axis
2193
+ // transitions, and/or clips — headphones-rig.json, for instance, is
2194
+ // transitions+parts only, with no "clips" of its own) is already
2195
+ // registered in the live viewer, and if so just adopt its ids instead
2196
+ // of reloading.
2197
+ const partIds = Object.keys(data.parts ?? {});
2198
+ const transitionAxes = Object.keys(data.transitions ?? {});
2199
+ const stateAxes = Object.keys(data.states ?? {});
2200
+ const clipIds = (data.clips ?? []).map(c => c.id);
2201
+ const alreadyLoaded = skipIfLoaded
2202
+ && (partIds.length > 0 || transitionAxes.length > 0 || stateAxes.length > 0 || clipIds.length > 0)
2203
+ && partIds.every(id => S.entry.viewer._partIndex[id]?.length)
2204
+ && transitionAxes.every(axis => S.entry.viewer._transitions[axis])
2205
+ && stateAxes.every(axis => S.entry.viewer._states[axis])
2206
+ && clipIds.every(id => S.entry.viewer._clips[id]);
2207
+ asset.clipIds = alreadyLoaded
2208
+ ? clipIds
2209
+ : await S.entry.api.loadClips(asset.url, { splatsDir: asset.splatsDir, defaults: asset.defaults });
2210
+ asset.status = 'ok';
2211
+
2212
+ // Re-apply any saved feather overrides — loadClips() just reset these
2213
+ // masks to their default softEdge, so the persisted value needs pushing
2214
+ // back in (mirrors buildMaskCard's same re-apply for animation volumes).
2215
+ // Not needed when we skipped the reload above — the boot sequence's
2216
+ // own opts.masks handling (see player.js) already applied these.
2217
+ if (!alreadyLoaded) {
2218
+ for (const m of asset.masks) {
2219
+ if (!S.maskConfigs[m.name]) S.maskConfigs[m.name] = {};
2220
+ const cfg = S.maskConfigs[m.name];
2221
+ if (cfg.feather != null && cfg.feather !== (m.softEdge ?? 0.05)) {
2222
+ S.entry.api.setMaskFeather(m.name, cfg.feather);
2223
+ }
2224
+ }
2225
+ }
2226
+
2227
+ debouncedSaveAssetsAttr();
2228
+ } catch (err) {
2229
+ asset.status = 'error';
2230
+ asset.clipIds = [];
2231
+ asset.masks = [];
2232
+ console.error(`[HoloSplat] asset ${i} ("${asset.url}") failed to load:`, err);
2233
+ }
2234
+ renderAssetsList();
2235
+ }
2236
+
2237
+ function addAsset() {
2238
+ S.assets.push({ url: '', splatsDir: '', defaults: {}, status: 'idle', clipIds: [], axes: {}, states: {}, parts: {}, masks: [] });
2239
+ renderAssetsList();
2240
+ debouncedSaveAssetsAttr();
2241
+ }
2242
+
2243
+ function removeAsset(i) {
2244
+ const asset = S.assets[i];
2245
+ if (asset?.clipIds?.length) S.entry?.api.unloadClips(asset.clipIds);
2246
+ S.assets.splice(i, 1);
2247
+ renderAssetsList();
2248
+ debouncedSaveAssetsAttr();
2249
+ }
2250
+
2251
+ function debouncedSaveAssetsAttr() {
2252
+ clearTimeout(_assetsSaveTimer);
2253
+ _assetsSaveTimer = setTimeout(saveAssetsAttr, 400);
2254
+ }
2255
+
2256
+ async function saveAssetsAttr() {
2257
+ if (!S.apiOnline) return;
2258
+ try {
2259
+ await fetch('/hs-api/js-clips', {
2260
+ method: 'POST',
2261
+ headers: { 'Content-Type': 'application/json' },
2262
+ body: JSON.stringify({
2263
+ page: window.location.pathname,
2264
+ clips: S.assets
2265
+ .filter(a => a.url)
2266
+ .map(a => {
2267
+ if (!a.splatsDir && !Object.keys(a.defaults ?? {}).length) return a.url;
2268
+ const obj = { url: a.url };
2269
+ if (a.splatsDir) obj.splatsDir = a.splatsDir;
2270
+ if (Object.keys(a.defaults ?? {}).length) obj.defaults = a.defaults;
2271
+ return obj;
2272
+ }),
2273
+ }),
2274
+ });
2275
+ } catch { }
2276
+ }
2277
+
2278
+ // Load (or reload) every configured asset's clips — called once the
2279
+ // player/animation is connected, mirroring how parts/masks apply on connect.
2280
+ function loadAllAssets() {
2281
+ S.assets.forEach((asset, i) => { if (asset.url) loadAsset(i, { skipIfLoaded: true }); });
2282
+ }
2283
+
2284
+ function switchTab(tab) {
2285
+ for (const btn of document.querySelectorAll('.__hs-tabbtn'))
2286
+ btn.classList.toggle('active', btn.dataset.tab === tab);
2287
+ for (const panel of document.querySelectorAll('.__hs-tabpanel'))
2288
+ panel.classList.toggle('active', panel.dataset.tab === tab);
2289
+ }
2290
+
2291
+ function saveUiState() {
2292
+ const state = {
2293
+ _scenes: {}, _masks: {}, sh: S.globalSh,
2294
+ panelMin: el('panel').classList.contains('minimized'),
2295
+ tlMin: el('tl')?.classList.contains('collapsed') ?? false,
2296
+ tab: document.querySelector('.__hs-tabbtn.active')?.dataset.tab || 'scenes',
2297
+ focalMarker: S.showFocalMarker,
2298
+ };
2299
+ for (const hd of document.querySelectorAll('.__hs-cpane-hd')) {
2300
+ const title = hd.querySelector('.__hs-cpane-title')?.textContent?.trim();
2301
+ const body = hd.nextElementSibling;
2302
+ if (title) state[title] = body.classList.contains('closed');
2303
+ }
2304
+ for (const card of document.querySelectorAll('.__hs-scard[data-sc-key]')) {
2305
+ const bd = card.querySelector('.__hs-scard-bd');
2306
+ if (bd) state._scenes[card.dataset.scKey] = bd.classList.contains('open');
2307
+ }
2308
+ for (const card of document.querySelectorAll('.__hs-scard[data-mask-key]')) {
2309
+ const bd = card.querySelector('.__hs-scard-bd');
2310
+ if (bd) state._masks[card.dataset.maskKey] = bd.classList.contains('open');
2311
+ }
2312
+ try { localStorage.setItem('__hs-ui', JSON.stringify(state)); } catch {}
2313
+ }
2314
+
2315
+ function loadUiState() {
2316
+ try {
2317
+ const saved = JSON.parse(localStorage.getItem('__hs-ui') || '{}');
2318
+ for (const hd of document.querySelectorAll('.__hs-cpane-hd')) {
2319
+ const title = hd.querySelector('.__hs-cpane-title')?.textContent?.trim();
2320
+ const body = hd.nextElementSibling;
2321
+ const tri = hd.querySelector('.__hs-cpane-tri');
2322
+ if (title && saved[title] === true) {
2323
+ body.classList.add('closed');
2324
+ tri.textContent = '▶';
2325
+ }
2326
+ }
2327
+ const scenes = saved._scenes || {};
2328
+ for (const card of document.querySelectorAll('.__hs-scard[data-sc-key]')) {
2329
+ const tri = card.querySelector('.__hs-stri');
2330
+ const bd = card.querySelector('.__hs-scard-bd');
2331
+ if (bd && scenes[card.dataset.scKey]) {
2332
+ bd.classList.add('open');
2333
+ if (tri) tri.textContent = '▼';
2334
+ }
2335
+ }
2336
+ const masks = saved._masks || {};
2337
+ for (const card of document.querySelectorAll('.__hs-scard[data-mask-key]')) {
2338
+ const tri = card.querySelector('.__hs-stri');
2339
+ const bd = card.querySelector('.__hs-scard-bd');
2340
+ if (bd && masks[card.dataset.maskKey]) {
2341
+ bd.classList.add('open');
2342
+ if (tri) tri.textContent = '▼';
2343
+ }
2344
+ }
2345
+ if (saved.sh != null) {
2346
+ S.globalSh = saved.sh;
2347
+ const shBtn = document.getElementById('__hs-sh-sel');
2348
+ if (shBtn) shBtn.textContent = SH_LABELS[S.globalSh] ?? S.globalSh;
2349
+ S.entry?.api?.setShDegree?.(S.globalSh)?.catch(() => {});
2350
+ }
2351
+ if (saved.focalMarker) {
2352
+ S.showFocalMarker = true;
2353
+ if (S.focalMarkerCb) S.focalMarkerCb.checked = true;
2354
+ }
2355
+ if (saved.panelMin) {
2356
+ el('panel').classList.add('minimized');
2357
+ el('min').textContent = '+';
2358
+ el('min').title = 'Restore panel';
2359
+ }
2360
+ if (saved.tab) switchTab(saved.tab);
2361
+ S._tlMinSaved = saved.tlMin === true;
2362
+ } catch {}
2363
+ }
2364
+
2365
+ function renderScenes() {
2366
+ const list = el('scenes-list');
2367
+ if (!S.markers || !Object.keys(S.markers).length) {
2368
+ list.innerHTML = '<div class="__hs-empty">No animation loaded</div>';
2369
+ return;
2370
+ }
2371
+ const scenes = computeScenes();
2372
+
2373
+ S.sceneCards = {};
2374
+ list.innerHTML = '';
2375
+ for (const scene of scenes) {
2376
+ const config = S.sceneConfigs[scene.name] || defaultConfig();
2377
+ S.sceneConfigs[scene.name] = config;
2378
+ list.appendChild(buildSceneCard(scene, config));
2379
+ }
2380
+ renderTimeline();
2381
+ loadUiState();
2382
+ }
2383
+
2384
+ function buildSceneCard(scene, cfg) {
2385
+ const card = document.createElement('div');
2386
+ card.className = '__hs-scard';
2387
+ card.dataset.scKey = scene.name;
2388
+ if (hasActiveConfig(cfg)) card.classList.add('__hs-scard--configured');
2389
+
2390
+ // ── Helpers ──────────────────────────────────────────────────────────────
2391
+ const update = (updater) => {
2392
+ const c = S.sceneConfigs[scene.name] || defaultConfig();
2393
+ updater(c);
2394
+ saveConfig(scene.name, c);
2395
+ card.classList.toggle('__hs-scard--configured', hasActiveConfig(c));
2396
+ renderTimeline();
2397
+ };
2398
+
2399
+ // Block: collapsible section with enable toggle in header.
2400
+ // buildBody(bbd, enable) — enable() programmatically turns the block on (for child controls).
2401
+ function mkBlock(container, name, enabled, buildBody) {
2402
+ const blk = container.appendChild(document.createElement('div'));
2403
+ blk.className = '__hs-ablock';
2404
+ const bhd = blk.appendChild(document.createElement('div'));
2405
+ bhd.className = '__hs-ablk-hd';
2406
+ const btri = bhd.appendChild(document.createElement('span'));
2407
+ btri.className = '__hs-stri'; btri.textContent = enabled ? '▼' : '▶';
2408
+ const bnm = bhd.appendChild(document.createElement('span'));
2409
+ bnm.className = '__hs-ablk-nm'; bnm.textContent = name;
2410
+ const { el: togEl, cb: togCb } = mkToggle(enabled, v => {
2411
+ update(c => {
2412
+ if (name === 'pan') c.pan.enabled = v;
2413
+ else if (name === 'zoom') c.zoom.enabled = v;
2414
+ else if (name === 'phone gyroscope') c.gyro = v;
2415
+ });
2416
+ if (bbd) { bbd.classList.toggle('open', v); btri.textContent = v ? '▼' : '▶'; }
2417
+ });
2418
+ bhd.appendChild(togEl);
2419
+ // Callable by child controls: turns the block on if it isn't already
2420
+ const enable = () => {
2421
+ if (togCb.checked) return;
2422
+ togCb.checked = true;
2423
+ update(c => {
2424
+ if (name === 'pan') c.pan.enabled = true;
2425
+ else if (name === 'zoom') c.zoom.enabled = true;
2426
+ });
2427
+ if (bbd) { bbd.classList.add('open'); btri.textContent = '▼'; }
2428
+ };
2429
+ let bbd = null;
2430
+ if (buildBody) {
2431
+ bbd = blk.appendChild(document.createElement('div'));
2432
+ bbd.className = `__hs-ablk-bd${enabled ? ' open' : ''}`;
2433
+ bhd.addEventListener('click', e => {
2434
+ if (togEl.contains(e.target)) return;
2435
+ const open = bbd.classList.toggle('open');
2436
+ btri.textContent = open ? '▼' : '▶';
2437
+ });
2438
+ buildBody(bbd, enable);
2439
+ }
2440
+ }
2441
+
2442
+ // ── Header ───────────────────────────────────────────────────────────────
2443
+ const hd = card.appendChild(document.createElement('div'));
2444
+ hd.className = '__hs-scard-hd';
2445
+
2446
+ // Progress fill bar (absolutely-positioned, behind flex content)
2447
+ const barEl = document.createElement('div');
2448
+ barEl.className = '__hs-scard-bar';
2449
+ hd.appendChild(barEl);
2450
+
2451
+ // Play / pause button (leftmost)
2452
+ const playBtn = document.createElement('button');
2453
+ playBtn.className = '__hs-scard-play';
2454
+ playBtn.textContent = '▶';
2455
+ playBtn.title = 'Play this scene';
2456
+ playBtn.addEventListener('click', e => {
2457
+ e.stopPropagation();
2458
+ if (S.activeMarker === scene.name) {
2459
+ // Toggle pause/play for active scene
2460
+ S.entry?.api.setAnimationPaused(!S.entry.viewer._animPaused);
2461
+ } else {
2462
+ // Jump to scene start and play
2463
+ const anim = S.entry?.api.animation;
2464
+ if (anim) { anim.seekFrame(scene.from); S.entry.api.setAnimationPaused(false); }
2465
+ el('range').value = scene.from;
2466
+ el('flbl').textContent = `${scene.from} / ${S.frameCount}`;
2467
+ }
2468
+ });
2469
+ hd.appendChild(playBtn);
2470
+
2471
+ // Expand triangle
2472
+ const tri = hd.appendChild(document.createElement('span'));
2473
+ tri.className = '__hs-stri'; tri.textContent = '▶';
2474
+
2475
+ // Blue dot — visible when this scene has any feature active
2476
+ const dot = hd.appendChild(document.createElement('span'));
2477
+ dot.className = '__hs-scard-dot';
2478
+
2479
+ // Linked-div badge — visible when a linkedId is configured
2480
+ const linkedBadge = hd.appendChild(document.createElement('span'));
2481
+ linkedBadge.className = '__hs-scard-linked' + (cfg.linkedId ? ' has-id' : '');
2482
+ linkedBadge.textContent = 'div';
2483
+ linkedBadge.title = cfg.linkedId ? `#${cfg.linkedId}` : '';
2484
+
2485
+ // Scene name — scrubable when this scene is active
2486
+ const nm = hd.appendChild(document.createElement('span'));
2487
+ nm.className = '__hs-scard-nm'; nm.textContent = scene.name;
2488
+ nm.addEventListener('mousedown', e => {
2489
+ if (S.activeMarker !== scene.name) return;
2490
+ e.stopPropagation();
2491
+ const wasPaused = S.entry?.viewer._animPaused ?? true;
2492
+ S.entry?.api.setAnimationPaused(true);
2493
+ const rect = nm.getBoundingClientRect();
2494
+ const doSeek = clientX => {
2495
+ const t = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
2496
+ const f = Math.round(scene.from + t * scene.frames);
2497
+ S.entry?.api.animation?.seekFrame(f);
2498
+ el('range').value = f;
2499
+ el('flbl').textContent = `${f} / ${S.frameCount}`;
2500
+ };
2501
+ doSeek(e.clientX);
2502
+ const onMove = ev => doSeek(ev.clientX);
2503
+ const onUp = () => {
2504
+ window.removeEventListener('mousemove', onMove);
2505
+ window.removeEventListener('mouseup', onUp);
2506
+ nm.style.userSelect = '';
2507
+ if (!wasPaused) S.entry?.api.setAnimationPaused(false);
2508
+ };
2509
+ nm.style.userSelect = 'none';
2510
+ window.addEventListener('mousemove', onMove);
2511
+ window.addEventListener('mouseup', onUp);
2512
+ });
2513
+
2514
+ const frSpan = hd.appendChild(document.createElement('span'));
2515
+ frSpan.className = '__hs-scard-fr'; frSpan.textContent = `${scene.frames}f`;
2516
+ const rngSpan = hd.appendChild(document.createElement('span'));
2517
+ rngSpan.className = '__hs-scard-range'; rngSpan.textContent = `${scene.from}–${scene.to}`;
2518
+
2519
+ // Register for rAF updates
2520
+ S.sceneCards[scene.name] = { playBtn, barEl, from: scene.from, frames: scene.frames };
2521
+
2522
+ const bd = card.appendChild(document.createElement('div'));
2523
+ bd.className = '__hs-scard-bd';
2524
+ hd.addEventListener('click', e => {
2525
+ if (e.target === playBtn || playBtn.contains(e.target)) return;
2526
+ const open = bd.classList.toggle('open');
2527
+ tri.textContent = open ? '▼' : '▶';
2528
+ saveUiState();
2529
+ });
2530
+
2531
+ // ── HTML element picker ───────────────────────────────────────────────────
2532
+ {
2533
+ const selRow = bd.appendChild(document.createElement('div'));
2534
+ selRow.className = '__hs-sel-row';
2535
+ const lbl = selRow.appendChild(document.createElement('span'));
2536
+ lbl.className = '__hs-sel-lbl'; lbl.textContent = 'html element';
2537
+ const sel = selRow.appendChild(document.createElement('select'));
2538
+ sel.className = '__hs-el-sel';
2539
+ sel.innerHTML = `<option value="">— none —</option>`;
2540
+ const pageEls = scanPageEls();
2541
+ const linkedId = cfg.linkedId || '';
2542
+ for (const e of pageEls)
2543
+ sel.innerHTML += `<option value="${e.id}"${e.id === linkedId ? ' selected' : ''}>#${e.id} &lt;${e.tag}&gt;</option>`;
2544
+ sel.addEventListener('change', () => {
2545
+ update(c => { c.linkedId = sel.value; });
2546
+ linkedBadge.classList.toggle('has-id', !!sel.value);
2547
+ linkedBadge.title = sel.value ? `#${sel.value}` : '';
2548
+ });
2549
+ }
2550
+
2551
+ bd.appendChild(document.createElement('div')).className = '__hs-sdiv';
2552
+
2553
+ // ── Scene playback ────────────────────────────────────────────────────────
2554
+ {
2555
+ const sec = bd.appendChild(document.createElement('div'));
2556
+ sec.style.cssText = 'padding:4px 0 6px;';
2557
+ const hdr = sec.appendChild(document.createElement('div'));
2558
+ hdr.style.cssText = 'font-size:0.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#999;padding:2px 0 6px;';
2559
+ hdr.textContent = 'scene playback';
2560
+
2561
+ const rbRow = sec.appendChild(document.createElement('div'));
2562
+ rbRow.className = '__hs-attr-row';
2563
+ for (const [value, label] of [['scroll', 'scroll'], ['auto', 'auto']]) {
2564
+ const rbLbl = rbRow.appendChild(document.createElement('label'));
2565
+ rbLbl.style.cssText = 'display:inline-flex;align-items:center;gap:4px;margin-right:12px;cursor:pointer;font-size:0.85rem;';
2566
+ const rb = document.createElement('input');
2567
+ rb.type = 'radio'; rb.name = `pb-${scene.name}`; rb.value = value;
2568
+ rb.checked = (cfg.playback || 'scroll') === value;
2569
+ rb.addEventListener('change', () => { if (rb.checked) update(c => c.playback = value); });
2570
+ rbLbl.appendChild(rb);
2571
+ rbLbl.appendChild(document.createTextNode(label));
2572
+ }
2573
+
2574
+ // Wait for timeline toggle
2575
+ const wRow = sec.appendChild(document.createElement('div'));
2576
+ wRow.className = '__hs-attr-row';
2577
+ wRow.style.marginTop = '4px';
2578
+ const wLbl = wRow.appendChild(document.createElement('span'));
2579
+ wLbl.className = '__hs-attr-lbl'; wLbl.textContent = 'wait for timeline';
2580
+ wRow.appendChild(mkToggle(cfg.waitForTimeline ?? false, v => update(c => c.waitForTimeline = v)).el);
2581
+ }
2582
+
2583
+ bd.appendChild(document.createElement('div')).className = '__hs-sdiv';
2584
+
2585
+ // ── Ping-pong ─────────────────────────────────────────────────────────────
2586
+ bd.appendChild(mkRow('ping-pong', mkToggle(cfg.pingpong, v => update(c => c.pingpong = v)).el));
2587
+
2588
+ // ── Play once ────────────────────────────────────────────────────────────
2589
+ bd.appendChild(mkRow('play once', mkToggle(cfg.playOnce, v => update(c => c.playOnce = v)).el));
2590
+
2591
+ bd.appendChild(document.createElement('div')).className = '__hs-sdiv';
2592
+
2593
+ // ── Blend in/out ──────────────────────────────────────────────────────────
2594
+ // % of this scene's own linked container height, crossfading the actual
2595
+ // rendered camera/object pose with the adjacent scene's — see
2596
+ // updateSceneBlend in player.js. The blue zone overlay is transient
2597
+ // feedback (shown while actively dragging/typing a value), not a
2598
+ // persisted toggle.
2599
+ {
2600
+ const showZone = () => showBlendOverlay(
2601
+ cfg.linkedId ? document.getElementById(cfg.linkedId) : null,
2602
+ cfg.blendIn, cfg.blendOut
2603
+ );
2604
+ bd.appendChild(mkRow('blend in',
2605
+ mkNum(cfg.blendIn, 0, 100, 1, '%', v => { update(c => c.blendIn = v); showZone(); },
2606
+ active => active ? showZone() : hideBlendOverlay()).el));
2607
+ bd.appendChild(mkRow('blend out',
2608
+ mkNum(cfg.blendOut, 0, 100, 1, '%', v => { update(c => c.blendOut = v); showZone(); },
2609
+ active => active ? showZone() : hideBlendOverlay()).el));
2610
+ }
2611
+
2612
+ bd.appendChild(document.createElement('div')).className = '__hs-sdiv';
2613
+
2614
+ // NOTE: an "Orbit" block (follow pointer, damping, reset speed/ease,
2615
+ // h/v limits) used to live here — removed as part of a deliberate
2616
+ // cleanup; will be re-added slowly later.
2617
+
2618
+ // ── Pan ───────────────────────────────────────────────────────────────────
2619
+ mkBlock(bd, 'pan', cfg.pan.enabled, (bbd, enable) => {
2620
+ bbd.appendChild(mkRow('damping',
2621
+ mkNum(cfg.pan.damping ?? 0, 0, 100, 1, '%', v => update(c => c.pan.damping = v)).el));
2622
+
2623
+ {
2624
+ const sel = document.createElement('select');
2625
+ sel.className = '__hs-el-sel'; sel.style.width = 'auto';
2626
+ for (const opt of ['right', 'left']) {
2627
+ const o = document.createElement('option'); o.value = opt; o.textContent = opt + ' click';
2628
+ if ((cfg.pan.button ?? 'right') === opt) o.selected = true;
2629
+ sel.appendChild(o);
2630
+ }
2631
+ sel.addEventListener('change', () => update(c => c.pan.button = sel.value));
2632
+ bbd.appendChild(mkRow('drag button', sel));
2633
+ }
2634
+
2635
+ const limitedDep = [];
2636
+ bbd.appendChild(mkRow('limited',
2637
+ mkToggle(cfg.pan.limited, v => {
2638
+ if (v) enable();
2639
+ update(c => c.pan.limited = v);
2640
+ limitedDep.forEach(r => r.style.display = v ? '' : 'none');
2641
+ }).el));
2642
+
2643
+ const radRow = mkRow('radius',
2644
+ mkNum(cfg.pan.radius, 1, 99999, 1, null, v => update(c => c.pan.radius = v)).el);
2645
+ radRow.style.display = cfg.pan.limited ? '' : 'none';
2646
+ limitedDep.push(radRow); bbd.appendChild(radRow);
2647
+ });
2648
+
2649
+ bd.appendChild(document.createElement('div')).className = '__hs-sdiv';
2650
+
2651
+ // ── Zoom ──────────────────────────────────────────────────────────────────
2652
+ mkBlock(bd, 'zoom', cfg.zoom.enabled, (bbd, enable) => {
2653
+ const limitedDep = [];
2654
+ bbd.appendChild(mkRow('limited',
2655
+ mkToggle(cfg.zoom.limited, v => {
2656
+ if (v) enable();
2657
+ update(c => c.zoom.limited = v);
2658
+ limitedDep.forEach(r => r.style.display = v ? '' : 'none');
2659
+ }).el));
2660
+
2661
+ const rangeRow = mkRow('range',
2662
+ mkNum(cfg.zoom.range, 1, 99999, 1, null, v => update(c => c.zoom.range = v)).el);
2663
+ rangeRow.style.display = cfg.zoom.limited ? '' : 'none';
2664
+ limitedDep.push(rangeRow); bbd.appendChild(rangeRow);
2665
+ });
2666
+
2667
+ bd.appendChild(document.createElement('div')).className = '__hs-sdiv';
2668
+
2669
+ // ── Phone gyroscope ───────────────────────────────────────────────────────
2670
+ mkBlock(bd, 'phone gyroscope', cfg.gyro, null);
2671
+
2672
+ return card;
2673
+ }
2674
+
2675
+ // ── Masks ────────────────────────────────────────────────────────────────────
2676
+
2677
+ function renderMasks() {
2678
+ const list = el('masks-list');
2679
+ const vols = S.entry?.viewer?._animation?.volumes ?? [];
2680
+ renderMaskSummary(el('website-masks-summary'), vols.map(v => ({ name: v.name, softEdge: v.softEdge })), 'Main Animation masks');
2681
+ if (!vols.length) {
2682
+ list.innerHTML = '<div class="__hs-empty">No mask volumes</div>';
2683
+ return;
2684
+ }
2685
+ list.innerHTML = '';
2686
+ for (const vol of vols) {
2687
+ if (!S.maskConfigs[vol.name]) S.maskConfigs[vol.name] = {};
2688
+ list.appendChild(buildMaskCard(vol));
2689
+ }
2690
+ loadUiState();
2691
+ }
2692
+
2693
+ function buildMaskCard(vol) {
2694
+ const card = document.createElement('div');
2695
+ card.className = '__hs-scard';
2696
+ card.dataset.maskKey = vol.name;
2697
+
2698
+ const defaultFeather = vol.softEdge ?? 0.05;
2699
+ const cfg = S.maskConfigs[vol.name] || {};
2700
+ if (cfg.feather != null && cfg.feather !== defaultFeather) card.classList.add('__hs-scard--configured');
2701
+
2702
+ // ── Header ───────────────────────────────────────────────────────────────
2703
+ const hd = card.appendChild(document.createElement('div'));
2704
+ hd.className = '__hs-scard-hd';
2705
+
2706
+ const tri = hd.appendChild(document.createElement('span'));
2707
+ tri.className = '__hs-stri'; tri.textContent = '▶';
2708
+
2709
+ const dot = hd.appendChild(document.createElement('span'));
2710
+ dot.className = '__hs-scard-dot';
2711
+
2712
+ const nm = hd.appendChild(document.createElement('span'));
2713
+ nm.className = '__hs-scard-nm'; nm.style.cursor = 'default';
2714
+ nm.textContent = vol.name;
2715
+
2716
+ const bd = card.appendChild(document.createElement('div'));
2717
+ bd.className = '__hs-scard-bd';
2718
+ hd.addEventListener('click', () => {
2719
+ const open = bd.classList.toggle('open');
2720
+ tri.textContent = open ? '▼' : '▶';
2721
+ saveUiState();
2722
+ });
2723
+
2724
+ // ── Feather (soft-edge falloff, in scene units) ───────────────────────────
2725
+ const current = cfg.feather ?? defaultFeather;
2726
+ bd.appendChild(mkRow('feather', mkNum(current, 0, 10, 0.01, null, v => {
2727
+ applyMaskFeather(vol.name, v);
2728
+ card.classList.toggle('__hs-scard--configured', v !== defaultFeather);
2729
+ }).el));
2730
+
2731
+ // Apply the saved/loaded feather to the live player immediately so the
2732
+ // viewport reflects the persisted value as soon as the card is built.
2733
+ if (current !== defaultFeather) S.entry?.api.setMaskFeather(vol.name, current);
2734
+
2735
+ return card;
2736
+ }
2737
+
2738
+ // ── Init ─────────────────────────────────────────────────────────────────────
2739
+ function init() {
2740
+ const style = document.createElement('style');
2741
+ style.textContent = CSS;
2742
+ document.head.appendChild(style);
2743
+
2744
+ const wrap = document.createElement('div');
2745
+ wrap.innerHTML = HTML;
2746
+ document.body.appendChild(wrap.firstElementChild);
2747
+
2748
+ // Toggle panel
2749
+ el('tab').addEventListener('click', () => {
2750
+ S.panelOpen = !S.panelOpen;
2751
+ el('panel').classList.toggle('closed', !S.panelOpen);
2752
+ });
2753
+
2754
+ // Minimize panel to just its toolbar
2755
+ el('min').addEventListener('click', () => {
2756
+ const minimized = el('panel').classList.toggle('minimized');
2757
+ el('min').textContent = minimized ? '+' : '−';
2758
+ el('min').title = minimized ? 'Restore panel' : 'Minimize panel';
2759
+ saveUiState();
2760
+ });
2761
+
2762
+ // Tabs
2763
+ for (const btn of document.querySelectorAll('.__hs-tabbtn')) {
2764
+ btn.addEventListener('click', () => {
2765
+ switchTab(btn.dataset.tab);
2766
+ saveUiState();
2767
+ });
2768
+ }
2769
+
2770
+ // Collapsible pane headers
2771
+ for (const hd of document.querySelectorAll('.__hs-cpane-hd')) {
2772
+ hd.addEventListener('click', () => {
2773
+ const body = hd.nextElementSibling;
2774
+ const tri = hd.querySelector('.__hs-cpane-tri');
2775
+ const closed = body.classList.toggle('closed');
2776
+ tri.textContent = closed ? '▶' : '▼';
2777
+ saveUiState();
2778
+ });
2779
+ }
2780
+
2781
+ {
2782
+ const { el: togEl, cb } = mkToggle(S.showFocalMarker, v => {
2783
+ S.showFocalMarker = v;
2784
+ if (!v && S.focalMarkerEl) S.focalMarkerEl.style.display = 'none';
2785
+ saveUiState();
2786
+ });
2787
+ S.focalMarkerCb = cb;
2788
+ el('fp-toggle-row').appendChild(mkRow('Visualize', togEl));
2789
+ }
2790
+
2791
+ loadUiState();
2792
+
2793
+ // Init page
2794
+ el('init').addEventListener('click', () => {
2795
+ if (document.querySelector('.hs-player')) {
2796
+ setStatus('Page already has a player', true); return;
2797
+ }
2798
+ const tag = document.createElement('hs-player');
2799
+ tag.id = 'hs-main';
2800
+ document.body.appendChild(tag);
2801
+ setStatus('Injected <hs-player id="hs-main"> — reload page to connect');
2802
+ });
2803
+
2804
+ // Reload / sync
2805
+ el('ran').addEventListener('click', reloadAnim);
2806
+ el('an').addEventListener('keydown', e => { if (e.key === 'Enter') reloadAnim(); });
2807
+ el('sync').addEventListener('click', syncParts);
2808
+ el('asset-add').addEventListener('click', addAsset);
2809
+
2810
+ el('pd').addEventListener('blur', () => {
2811
+ renderParts();
2812
+ const dir = el('pd').value.trim();
2813
+ if (S.apiOnline && dir !== S.lastSavedDir) {
2814
+ S.lastSavedDir = dir;
2815
+ refreshDiskFiles(); // splats dir changed — disk listing may now be stale
2816
+ saveDirUrl(dir).then(() => setStatus(dir ? `Saved splats dir: ${dir}` : 'Cleared splats dir'));
2817
+ }
2818
+ });
2819
+ el('pd').addEventListener('keydown', e => { if (e.key === 'Enter') el('pd').blur(); });
2820
+
2821
+ // ── Timeline ──────────────────────────────────────────────────────────────
2822
+ const tl = document.createElement('div');
2823
+ tl.id = '__hs-tl';
2824
+ tl.innerHTML = `
2825
+ <div id="__hs-tl-btns">
2826
+ <button class="__hs-tl-btn" id="__hs-tl-tostart" title="Jump to start">|◀</button>
2827
+ <button class="__hs-tl-btn" id="__hs-rev" title="Play backward">◀</button>
2828
+ <button class="__hs-tl-btn" id="__hs-playpause" title="Play">▶</button>
2829
+ <button class="__hs-tl-btn" id="__hs-tl-toend" title="Jump to end">▶|</button>
2830
+ </div>
2831
+ <div id="__hs-tl-track">
2832
+ <div id="__hs-tl-labels"></div>
2833
+ <div id="__hs-tl-bar"><div id="__hs-tl-fill"></div></div>
2834
+ </div>
2835
+ <div id="__hs-tl-footer">
2836
+ <span id="__hs-flbl">no animation</span>
2837
+ <span id="__hs-scene-nm"></span>
2838
+ </div>
2839
+ <div id="__hs-tl-meta">
2840
+ <div>frame rate&nbsp;&nbsp;: <span id="__hs-tl-fps">—</span>&nbsp;<span class="__hs-tl-dot">●</span></div>
2841
+ <div>frame per em : <span id="__hs-tl-fpe">—</span>&nbsp;<span class="__hs-tl-dot">●</span></div>
2842
+ </div>
2843
+ <input type="range" id="__hs-range" style="display:none" min="0" max="0" value="0" step="1">
2844
+ `;
2845
+ document.body.appendChild(tl);
2846
+
2847
+ // Minimize toggle lives outside the timeline panel, like the #__hs-tab/#__hs-panel pair.
2848
+ const tlMin = document.createElement('button');
2849
+ tlMin.id = '__hs-tl-min';
2850
+ tlMin.title = 'Minimize timeline';
2851
+ tlMin.textContent = '▼';
2852
+ document.body.appendChild(tlMin);
2853
+
2854
+ // Minimize timeline to just its playback controls
2855
+ function setTlCollapsed(collapsed) {
2856
+ tl.classList.toggle('collapsed', collapsed);
2857
+ tlMin.textContent = collapsed ? '▲' : '▼';
2858
+ tlMin.title = collapsed ? 'Restore timeline' : 'Minimize timeline';
2859
+ document.documentElement.style.setProperty('--hs-tl-h', collapsed ? '56px' : '160px');
2860
+ // Labels were skipped while hidden (0-width) — recompute now they're visible.
2861
+ if (!collapsed) requestAnimationFrame(() => adjustLabelOverlaps(el('tl-labels')));
2862
+ }
2863
+ tlMin.addEventListener('click', () => {
2864
+ setTlCollapsed(!tl.classList.contains('collapsed'));
2865
+ saveUiState();
2866
+ });
2867
+ if (S._tlMinSaved) setTlCollapsed(true);
2868
+
2869
+ // Timeline bar: click + drag to seek
2870
+ const tlBar = el('tl-bar');
2871
+ let tlScrubbing = false;
2872
+ function tlSeek(clientX) {
2873
+ const rect = tlBar.getBoundingClientRect();
2874
+ const t = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
2875
+ seekFrame(Math.round(t * Math.max(0, S.frameCount - 1)));
2876
+ }
2877
+ tlBar.addEventListener('mousedown', e => {
2878
+ if (e.button !== 0) return;
2879
+ tlScrubbing = true; S.scrubbing = true;
2880
+ tlSeek(e.clientX); e.preventDefault();
2881
+ });
2882
+ document.addEventListener('mousemove', e => { if (tlScrubbing) tlSeek(e.clientX); });
2883
+ document.addEventListener('mouseup', () => { if (tlScrubbing) { tlScrubbing = false; S.scrubbing = false; } });
2884
+
2885
+ // Playback controls
2886
+ el('playpause').addEventListener('click', () => {
2887
+ if (!S.entry) return;
2888
+ S.animEverPlayed = true;
2889
+ const paused = S.entry.viewer._animPaused;
2890
+ const reverse = S.entry.api.animation?.direction === -1;
2891
+ if (!paused && !reverse) {
2892
+ S.entry.api.setAnimationPaused(true);
2893
+ } else {
2894
+ if (S.entry.api.animation) S.entry.api.animation.direction = 1;
2895
+ S.entry.api.setAnimationPaused(false);
2896
+ }
2897
+ updatePlayState();
2898
+ });
2899
+ el('rev').addEventListener('click', () => {
2900
+ if (!S.entry) return;
2901
+ S.animEverPlayed = true;
2902
+ const paused = S.entry.viewer._animPaused;
2903
+ const reverse = S.entry.api.animation?.direction === -1;
2904
+ if (!paused && reverse) {
2905
+ S.entry.api.setAnimationPaused(true);
2906
+ } else {
2907
+ if (S.entry.api.animation) S.entry.api.animation.direction = -1;
2908
+ S.entry.api.setAnimationPaused(false);
2909
+ }
2910
+ updatePlayState();
2911
+ });
2912
+ el('tl-tostart').addEventListener('click', () => {
2913
+ if (!S.entry) return;
2914
+ seekFrame(0); S.entry.api.setAnimationPaused(true); updatePlayState();
2915
+ });
2916
+ el('tl-toend').addEventListener('click', () => {
2917
+ if (!S.entry || !S.frameCount) return;
2918
+ seekFrame(S.frameCount - 1); S.entry.api.setAnimationPaused(true); updatePlayState();
2919
+ });
2920
+
2921
+ _apiReady = apiCheck();
2922
+ _apiReady.then(() => {
2923
+ if (S.apiOnline) loadPageState().then(() => { renderScenes(); renderMasks(); });
2924
+ });
2925
+
2926
+ // Start rAF loop for smooth scene card progress bars
2927
+ requestAnimationFrame(rafBars);
2928
+
2929
+ // Find player — it may already be registered or arrive shortly
2930
+ const players = window.__hsPlayers || [];
2931
+ if (players.length) {
2932
+ connectEntry(players[0]);
2933
+ } else {
2934
+ setStatus('Waiting for player…');
2935
+ const poll = setInterval(() => {
2936
+ const ps = window.__hsPlayers || [];
2937
+ if (ps.length) { clearInterval(poll); connectEntry(ps[0]); }
2938
+ }, 200);
2939
+ }
2940
+ }
2941
+
2942
+ if (document.readyState === 'loading') {
2943
+ document.addEventListener('DOMContentLoaded', init);
2944
+ } else {
2945
+ init();
2946
+ }
2947
+ })();