holosplat 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +890 -0
- package/bin/holosplat.cjs +374 -0
- package/dist/holosplat.esm.js +766 -0
- package/dist/holosplat.esm.js.map +7 -0
- package/dist/holosplat.iife.js +766 -0
- package/dist/holosplat.iife.js.map +7 -0
- package/holosplat/editor.js +2947 -0
- package/holosplat/index.html +614 -0
- package/holosplat/stats.js +101 -0
- package/package.json +30 -0
- package/server.py +560 -0
- package/src/server.js +198 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>HoloSplat</title>
|
|
7
|
+
<style>
|
|
8
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
+
body {
|
|
10
|
+
background: #111; color: #eee;
|
|
11
|
+
font-family: system-ui, sans-serif;
|
|
12
|
+
height: 100dvh; display: flex; flex-direction: column; overflow: hidden;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* ── Toolbar ── */
|
|
16
|
+
#toolbar {
|
|
17
|
+
display: flex; align-items: center; gap: 8px;
|
|
18
|
+
padding: 6px 12px; background: #1a1a1a;
|
|
19
|
+
border-bottom: 1px solid #333; flex-shrink: 0;
|
|
20
|
+
}
|
|
21
|
+
#toolbar h1 { font-size: 0.9rem; font-weight: 700; white-space: nowrap; }
|
|
22
|
+
#title-dot { color: #ff9a44; margin-left: 2px; display: none; }
|
|
23
|
+
#configPath {
|
|
24
|
+
width: 220px; background: #252525; border: 1px solid #3a3a3a; color: #eee;
|
|
25
|
+
padding: 3px 7px; border-radius: 3px; font-size: 0.75rem;
|
|
26
|
+
}
|
|
27
|
+
#status {
|
|
28
|
+
flex: 1; font-size: 0.72rem; color: #666;
|
|
29
|
+
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
30
|
+
}
|
|
31
|
+
#status.err { color: #ff5555; }
|
|
32
|
+
#api-badge {
|
|
33
|
+
font-size: 0.65rem; padding: 2px 6px; border-radius: 10px;
|
|
34
|
+
background: #1e2e1e; border: 1px solid #2a4a2a; color: #5a9a5a;
|
|
35
|
+
white-space: nowrap;
|
|
36
|
+
}
|
|
37
|
+
#api-badge.offline { background: #2e1e1e; border-color: #4a2a2a; color: #9a5a5a; }
|
|
38
|
+
|
|
39
|
+
.btn {
|
|
40
|
+
background: #2a2a2a; border: 1px solid #3a3a3a; color: #ccc;
|
|
41
|
+
padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 0.78rem;
|
|
42
|
+
white-space: nowrap; flex-shrink: 0;
|
|
43
|
+
}
|
|
44
|
+
.btn:hover { background: #333; color: #fff; }
|
|
45
|
+
.btn.primary { background: #2d4a7a; border-color: #3a6aaa; color: #fff; }
|
|
46
|
+
.btn.primary:hover { background: #3a5a9a; }
|
|
47
|
+
|
|
48
|
+
/* ── Main ── */
|
|
49
|
+
#main { display: flex; flex: 1; overflow: hidden; }
|
|
50
|
+
|
|
51
|
+
/* ── Sidebar ── */
|
|
52
|
+
#sidebar {
|
|
53
|
+
width: 340px; flex-shrink: 0;
|
|
54
|
+
background: #1a1a1a; border-right: 1px solid #333;
|
|
55
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
56
|
+
}
|
|
57
|
+
.pane { border-bottom: 1px solid #222; flex-shrink: 0; }
|
|
58
|
+
.pane-title {
|
|
59
|
+
font-size: 0.67rem; font-weight: 700; text-transform: uppercase;
|
|
60
|
+
letter-spacing: 0.07em; color: #555;
|
|
61
|
+
padding: 6px 10px 4px;
|
|
62
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
63
|
+
}
|
|
64
|
+
.pane-title .dim { font-weight: 400; text-transform: none; letter-spacing: 0; color: #3a3a3a; }
|
|
65
|
+
|
|
66
|
+
/* Files pane */
|
|
67
|
+
#files-pane { padding: 6px 10px 8px; }
|
|
68
|
+
.file-row { display: flex; align-items: center; gap: 5px; margin-top: 5px; }
|
|
69
|
+
.file-lbl { font-size: 0.7rem; color: #555; width: 34px; flex-shrink: 0; }
|
|
70
|
+
.file-row input {
|
|
71
|
+
flex: 1; min-width: 0;
|
|
72
|
+
background: #1e1e1e; border: 1px solid #2e2e2e; color: #ccc;
|
|
73
|
+
padding: 3px 6px; border-radius: 3px; font-size: 0.73rem;
|
|
74
|
+
}
|
|
75
|
+
.file-row input:focus { border-color: #3a7aff; outline: none; }
|
|
76
|
+
.file-row .btn { padding: 3px 7px; font-size: 0.7rem; }
|
|
77
|
+
|
|
78
|
+
/* Markers pane */
|
|
79
|
+
#markers-pane { overflow-y: auto; max-height: 150px; }
|
|
80
|
+
#markers-pane .pane-title { position: sticky; top: 0; background: #1a1a1a; z-index: 1; }
|
|
81
|
+
.marker-row {
|
|
82
|
+
display: flex; justify-content: space-between; align-items: center;
|
|
83
|
+
padding: 3px 10px; cursor: pointer; font-size: 0.73rem;
|
|
84
|
+
}
|
|
85
|
+
.marker-row:hover { background: #1e1e1e; }
|
|
86
|
+
.marker-name { color: #aaa; }
|
|
87
|
+
.marker-frame { color: #444; font-variant-numeric: tabular-nums; font-size: 0.68rem; }
|
|
88
|
+
.empty { padding: 8px 10px; color: #3a3a3a; font-size: 0.73rem; font-style: italic; }
|
|
89
|
+
|
|
90
|
+
/* Timeline pane */
|
|
91
|
+
#timeline-pane { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
|
92
|
+
#timeline { flex: 1; overflow-y: auto; }
|
|
93
|
+
.add-row {
|
|
94
|
+
display: flex; gap: 4px; padding: 6px 10px;
|
|
95
|
+
flex-shrink: 0; border-top: 1px solid #1e1e1e; flex-wrap: wrap;
|
|
96
|
+
}
|
|
97
|
+
.add-row .btn { flex: 1; min-width: 56px; font-size: 0.68rem; padding: 3px 4px; text-align: center; }
|
|
98
|
+
|
|
99
|
+
/* Act rows */
|
|
100
|
+
.act-row {
|
|
101
|
+
display: flex; align-items: center; gap: 4px;
|
|
102
|
+
padding: 5px 8px; border-bottom: 1px solid #1a1a1a;
|
|
103
|
+
}
|
|
104
|
+
.act-row:hover { background: #1d1d1d; }
|
|
105
|
+
.act-row select {
|
|
106
|
+
background: #1e1e1e; border: 1px solid #2e2e2e; color: #bbb;
|
|
107
|
+
padding: 2px 3px; border-radius: 3px; font-size: 0.7rem; min-width: 0;
|
|
108
|
+
}
|
|
109
|
+
.act-row select:focus { border-color: #3a7aff; outline: none; }
|
|
110
|
+
.type-sel { width: 68px; flex-shrink: 0; }
|
|
111
|
+
.from-sel, .to-sel, .frame-sel { flex: 1; }
|
|
112
|
+
.arrow { color: #444; font-size: 0.75rem; flex-shrink: 0; }
|
|
113
|
+
|
|
114
|
+
/* Height drag field */
|
|
115
|
+
.h-field {
|
|
116
|
+
display: flex; align-items: center; gap: 1px; flex-shrink: 0;
|
|
117
|
+
background: #1e1e1e; border: 1px solid #2e2e2e; border-radius: 3px;
|
|
118
|
+
padding: 2px 5px; cursor: ew-resize; user-select: none; min-width: 50px;
|
|
119
|
+
}
|
|
120
|
+
.h-field:hover { border-color: #444; }
|
|
121
|
+
.h-val { font-size: 0.73rem; color: #ccc; font-variant-numeric: tabular-nums; min-width: 26px; text-align: right; }
|
|
122
|
+
.h-unit { font-size: 0.62rem; color: #444; }
|
|
123
|
+
|
|
124
|
+
.del-btn {
|
|
125
|
+
background: none; border: none; color: #333; cursor: pointer;
|
|
126
|
+
font-size: 0.95rem; line-height: 1; padding: 0 2px; flex-shrink: 0;
|
|
127
|
+
}
|
|
128
|
+
.del-btn:hover { color: #ff5555; }
|
|
129
|
+
|
|
130
|
+
/* Type accent stripe */
|
|
131
|
+
.act-type--act .type-sel { border-left: 2px solid #3a7aff; }
|
|
132
|
+
.act-type--hold .type-sel { border-left: 2px solid #ffa040; }
|
|
133
|
+
.act-type--pingpong .type-sel { border-left: 2px solid #40d080; }
|
|
134
|
+
.act-type--freecamera .type-sel { border-left: 2px solid #c060e0; }
|
|
135
|
+
|
|
136
|
+
/* ── Preview ── */
|
|
137
|
+
#preview { flex: 1; display: flex; flex-direction: column; background: #0a0a0a; overflow: hidden; }
|
|
138
|
+
#preview-mount { flex: 1; position: relative; overflow: hidden; }
|
|
139
|
+
|
|
140
|
+
/* Let the hs-player fill the mount */
|
|
141
|
+
#preview-mount > * { position: absolute !important; inset: 0 !important; width: 100% !important; height: 100% !important; }
|
|
142
|
+
|
|
143
|
+
#no-gpu {
|
|
144
|
+
display: none; position: absolute; inset: 0;
|
|
145
|
+
align-items: center; justify-content: center; flex-direction: column;
|
|
146
|
+
gap: 8px; background: #0a0a0a; color: #444; font-size: 0.85rem; text-align: center;
|
|
147
|
+
}
|
|
148
|
+
#no-gpu.show { display: flex; }
|
|
149
|
+
|
|
150
|
+
/* Scrubber */
|
|
151
|
+
#scrub-row {
|
|
152
|
+
display: flex; align-items: center; gap: 8px;
|
|
153
|
+
padding: 6px 12px; background: #1a1a1a; border-top: 1px solid #222;
|
|
154
|
+
flex-shrink: 0;
|
|
155
|
+
}
|
|
156
|
+
#scrubber { flex: 1; accent-color: #3a7aff; cursor: pointer; }
|
|
157
|
+
#frame-lbl {
|
|
158
|
+
font-size: 0.7rem; color: #555; white-space: nowrap;
|
|
159
|
+
min-width: 110px; text-align: right; font-variant-numeric: tabular-nums;
|
|
160
|
+
}
|
|
161
|
+
</style>
|
|
162
|
+
</head>
|
|
163
|
+
<body>
|
|
164
|
+
|
|
165
|
+
<div id="toolbar">
|
|
166
|
+
<h1>HoloSplat<span id="title-dot">●</span></h1>
|
|
167
|
+
<input id="configPath" value="hs-config.json" spellcheck="false" title="Config file path (relative to project root)">
|
|
168
|
+
<button class="btn" onclick="loadConfig()">Load</button>
|
|
169
|
+
<span id="status">Connecting…</span>
|
|
170
|
+
<span id="api-badge" class="offline">API offline</span>
|
|
171
|
+
<button class="btn primary" id="saveBtn" onclick="handleSave()">Save</button>
|
|
172
|
+
<button class="btn" onclick="exportConfig()">Export</button>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<div id="main">
|
|
176
|
+
<div id="sidebar">
|
|
177
|
+
|
|
178
|
+
<div class="pane" id="files-pane">
|
|
179
|
+
<div class="pane-title">Files</div>
|
|
180
|
+
<div class="file-row">
|
|
181
|
+
<span class="file-lbl">Scene</span>
|
|
182
|
+
<input id="sceneInput" placeholder="scenes/scene.spz" list="scene-list" spellcheck="false">
|
|
183
|
+
<datalist id="scene-list"></datalist>
|
|
184
|
+
<button class="btn" onclick="reloadScene()" title="Reload scene">↺</button>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="file-row">
|
|
187
|
+
<span class="file-lbl">Anim</span>
|
|
188
|
+
<input id="animInput" placeholder="blender/export.json" list="anim-list" spellcheck="false">
|
|
189
|
+
<datalist id="anim-list"></datalist>
|
|
190
|
+
<button class="btn" onclick="reloadAnim()" title="Reload animation">↺</button>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
<div class="pane" id="markers-pane">
|
|
195
|
+
<div class="pane-title">Markers <span class="dim">(click to seek)</span></div>
|
|
196
|
+
<div id="markers-list"><div class="empty">Load an animation to see markers</div></div>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div id="timeline-pane">
|
|
200
|
+
<div class="pane-title">
|
|
201
|
+
Timeline
|
|
202
|
+
<span class="dim" id="total-height">0vh total</span>
|
|
203
|
+
</div>
|
|
204
|
+
<div id="timeline"></div>
|
|
205
|
+
<div class="add-row">
|
|
206
|
+
<button class="btn" onclick="addAct('act')">+ Act</button>
|
|
207
|
+
<button class="btn" onclick="addAct('hold')">+ Hold</button>
|
|
208
|
+
<button class="btn" onclick="addAct('pingpong')">+ Pingpong</button>
|
|
209
|
+
<button class="btn" onclick="addAct('freecamera')">+ Freecam</button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<div id="preview">
|
|
216
|
+
<div id="preview-mount">
|
|
217
|
+
<div id="no-gpu">
|
|
218
|
+
<div>WebGPU not available</div>
|
|
219
|
+
<small style="color:#333">Use Chrome 113+ or Edge 113+ on localhost / https://</small>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
<div id="scrub-row">
|
|
223
|
+
<input type="range" id="scrubber" min="0" max="0" value="0" step="1"
|
|
224
|
+
oninput="seekFrame(+this.value)">
|
|
225
|
+
<span id="frame-lbl">no animation loaded</span>
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
|
|
230
|
+
<script src="../dist/holosplat.iife.js"></script>
|
|
231
|
+
<script>
|
|
232
|
+
// ── State ──────────────────────────────────────────────────────────────────────
|
|
233
|
+
const S = {
|
|
234
|
+
apiAvailable: false,
|
|
235
|
+
config: { version: 1, scene: '', animation: '', acts: [] },
|
|
236
|
+
markers: {}, // { markerName: frameNumber }
|
|
237
|
+
frameCount: 0,
|
|
238
|
+
player: null,
|
|
239
|
+
dirty: false,
|
|
240
|
+
_seq: 1,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ── DOM shorthand ──────────────────────────────────────────────────────────────
|
|
244
|
+
const $ = id => document.getElementById(id);
|
|
245
|
+
|
|
246
|
+
function setStatus(msg, err = false) {
|
|
247
|
+
$('status').textContent = msg;
|
|
248
|
+
$('status').className = err ? 'err' : '';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function setDirty(v) {
|
|
252
|
+
S.dirty = v;
|
|
253
|
+
$('title-dot').style.display = v ? 'inline' : 'none';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function updateTotal() {
|
|
257
|
+
const t = S.config.acts.reduce((s, a) => s + (a.height || 0), 0);
|
|
258
|
+
$('total-height').textContent = `${t}vh total`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function updateApiBadge() {
|
|
262
|
+
const b = $('api-badge');
|
|
263
|
+
if (S.apiAvailable) {
|
|
264
|
+
b.textContent = 'API online';
|
|
265
|
+
b.className = '';
|
|
266
|
+
$('saveBtn').textContent = 'Save';
|
|
267
|
+
$('saveBtn').title = `Save to ${$('configPath').value}`;
|
|
268
|
+
} else {
|
|
269
|
+
b.textContent = 'API offline';
|
|
270
|
+
b.className = 'offline';
|
|
271
|
+
$('saveBtn').textContent = 'Export';
|
|
272
|
+
$('saveBtn').title = 'Download hs-config.json (server not running)';
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── API ────────────────────────────────────────────────────────────────────────
|
|
277
|
+
async function apiCheck() {
|
|
278
|
+
try {
|
|
279
|
+
const r = await fetch('/hs-api/ls');
|
|
280
|
+
if (!r.ok) throw 0;
|
|
281
|
+
S.apiAvailable = true;
|
|
282
|
+
const { spz, json } = await r.json();
|
|
283
|
+
setDatalist('scene-list', spz);
|
|
284
|
+
setDatalist('anim-list', json);
|
|
285
|
+
} catch {
|
|
286
|
+
S.apiAvailable = false;
|
|
287
|
+
}
|
|
288
|
+
updateApiBadge();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function setDatalist(id, items) {
|
|
292
|
+
$(id).innerHTML = (items || []).map(f => `<option value="${f}">`).join('');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function apiRead(path) {
|
|
296
|
+
const r = await fetch(`/hs-api/file?path=${encodeURIComponent(path)}`);
|
|
297
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
298
|
+
return r.json();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function apiWrite(path, data) {
|
|
302
|
+
const r = await fetch(`/hs-api/file?path=${encodeURIComponent(path)}`, {
|
|
303
|
+
method: 'PUT',
|
|
304
|
+
headers: { 'Content-Type': 'application/json' },
|
|
305
|
+
body: JSON.stringify(data, null, 2),
|
|
306
|
+
});
|
|
307
|
+
if (!r.ok) throw new Error(`${r.status}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Config ─────────────────────────────────────────────────────────────────────
|
|
311
|
+
async function loadConfig() {
|
|
312
|
+
const path = $('configPath').value.trim() || 'hs-config.json';
|
|
313
|
+
setStatus('Loading config…');
|
|
314
|
+
try {
|
|
315
|
+
const cfg = await apiRead(path);
|
|
316
|
+
S.config = cfg;
|
|
317
|
+
S.config.acts = (cfg.acts || []).map(a => ({ ...a, id: a.id || `act-${S._seq++}` }));
|
|
318
|
+
$('sceneInput').value = cfg.scene || '';
|
|
319
|
+
$('animInput').value = cfg.animation || '';
|
|
320
|
+
renderTimeline();
|
|
321
|
+
setDirty(false);
|
|
322
|
+
setStatus(`Loaded: ${path}`);
|
|
323
|
+
if (cfg.scene) await loadScene(cfg.scene);
|
|
324
|
+
if (cfg.animation) await loadAnim(cfg.animation);
|
|
325
|
+
} catch {
|
|
326
|
+
setStatus('No config found — starting fresh');
|
|
327
|
+
S.config = { version: 1, scene: '', animation: '', acts: [] };
|
|
328
|
+
renderTimeline();
|
|
329
|
+
setDirty(false);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function handleSave() {
|
|
334
|
+
if (!S.apiAvailable) { exportConfig(); return; }
|
|
335
|
+
const path = $('configPath').value.trim() || 'hs-config.json';
|
|
336
|
+
setStatus('Saving…');
|
|
337
|
+
try {
|
|
338
|
+
await apiWrite(path, buildConfig());
|
|
339
|
+
setStatus(`Saved: ${path}`);
|
|
340
|
+
setDirty(false);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
setStatus(`Save failed: ${e.message}`, true);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function exportConfig() {
|
|
347
|
+
const blob = new Blob([JSON.stringify(buildConfig(), null, 2)], { type: 'application/json' });
|
|
348
|
+
const a = document.createElement('a');
|
|
349
|
+
a.href = URL.createObjectURL(blob);
|
|
350
|
+
a.download = 'hs-config.json';
|
|
351
|
+
a.click();
|
|
352
|
+
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
|
353
|
+
setStatus('Downloaded hs-config.json');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function buildConfig() {
|
|
357
|
+
return {
|
|
358
|
+
version: 1,
|
|
359
|
+
scene: $('sceneInput').value.trim(),
|
|
360
|
+
animation: $('animInput').value.trim(),
|
|
361
|
+
acts: S.config.acts.map(({ id, type, from, to, frame, height }) => {
|
|
362
|
+
const a = { id, type, height };
|
|
363
|
+
if (type === 'hold') a.frame = frame || null;
|
|
364
|
+
else { a.from = from || null; a.to = to || null; }
|
|
365
|
+
return a;
|
|
366
|
+
}),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Player ─────────────────────────────────────────────────────────────────────
|
|
371
|
+
function toUrl(rel) {
|
|
372
|
+
if (!rel) return null;
|
|
373
|
+
return rel.startsWith('http') ? rel : '/' + rel.replace(/^\/+/, '');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function ensurePlayer() {
|
|
377
|
+
if (S.player) return true;
|
|
378
|
+
if (!navigator.gpu) { $('no-gpu').classList.add('show'); return false; }
|
|
379
|
+
S.player = HoloSplat.player($('preview-mount'), {});
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function loadScene(rel) {
|
|
384
|
+
if (!rel) return;
|
|
385
|
+
if (!await ensurePlayer()) return;
|
|
386
|
+
const url = toUrl(rel);
|
|
387
|
+
setStatus('Loading scene…');
|
|
388
|
+
try {
|
|
389
|
+
await S.player.load(url);
|
|
390
|
+
setStatus('Scene ready');
|
|
391
|
+
} catch (e) {
|
|
392
|
+
setStatus(`Scene error: ${e.message}`, true);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function loadAnim(rel) {
|
|
397
|
+
if (!rel) return;
|
|
398
|
+
if (!await ensurePlayer()) return;
|
|
399
|
+
const url = toUrl(rel);
|
|
400
|
+
setStatus('Loading animation…');
|
|
401
|
+
try {
|
|
402
|
+
const result = S.player.loadAnim(url);
|
|
403
|
+
if (result?.then) await result;
|
|
404
|
+
// Poll until animation instance is available (loadAnim may be fire-and-forget internally)
|
|
405
|
+
if (!S.player.animation) {
|
|
406
|
+
await new Promise((res, rej) => {
|
|
407
|
+
let n = 0;
|
|
408
|
+
const t = setInterval(() => {
|
|
409
|
+
if (S.player.animation) { clearInterval(t); res(); }
|
|
410
|
+
else if (++n > 60) { clearInterval(t); rej(new Error('timeout')); }
|
|
411
|
+
}, 100);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
S.player.setAnimationPaused(true);
|
|
415
|
+
const anim = S.player.animation;
|
|
416
|
+
S.markers = anim.markers || {};
|
|
417
|
+
S.frameCount = anim.frameCount || 0;
|
|
418
|
+
$('scrubber').max = Math.max(0, S.frameCount - 1);
|
|
419
|
+
$('scrubber').value = 0;
|
|
420
|
+
$('frame-lbl').textContent = `frame 0 / ${S.frameCount}`;
|
|
421
|
+
renderMarkers();
|
|
422
|
+
renderTimeline(); // re-render so marker selects are populated
|
|
423
|
+
setStatus(`${S.frameCount} frames · ${Object.keys(S.markers).length} markers`);
|
|
424
|
+
} catch (e) {
|
|
425
|
+
setStatus(`Animation error: ${e.message}`, true);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function reloadScene() { loadScene($('sceneInput').value.trim()); setDirty(true); }
|
|
430
|
+
function reloadAnim() { loadAnim($('animInput').value.trim()); setDirty(true); }
|
|
431
|
+
|
|
432
|
+
// ── Scrubber ───────────────────────────────────────────────────────────────────
|
|
433
|
+
function seekFrame(n) {
|
|
434
|
+
if (S.player?.animation) S.player.animation.seekFrame(n);
|
|
435
|
+
$('frame-lbl').textContent = `frame ${Math.round(n)} / ${S.frameCount}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ── Markers ────────────────────────────────────────────────────────────────────
|
|
439
|
+
function renderMarkers() {
|
|
440
|
+
const entries = Object.entries(S.markers).sort((a, b) => a[1] - b[1]);
|
|
441
|
+
$('markers-list').innerHTML = entries.length
|
|
442
|
+
? entries.map(([n, f]) =>
|
|
443
|
+
`<div class="marker-row" onclick="seekFrame(${f})" title="frame ${f}">
|
|
444
|
+
<span class="marker-name">${n}</span>
|
|
445
|
+
<span class="marker-frame">${f}</span>
|
|
446
|
+
</div>`).join('')
|
|
447
|
+
: '<div class="empty">No markers found</div>';
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Timeline ───────────────────────────────────────────────────────────────────
|
|
451
|
+
function markerOpts(selected, emptyLabel) {
|
|
452
|
+
const sorted = Object.entries(S.markers).sort((a, b) => a[1] - b[1]);
|
|
453
|
+
let html = `<option value="">${emptyLabel}</option>`;
|
|
454
|
+
// Show selected value even if not yet in loaded markers
|
|
455
|
+
if (selected && !S.markers[selected])
|
|
456
|
+
html += `<option value="${selected}" selected>${selected} (?)</option>`;
|
|
457
|
+
for (const [n, f] of sorted)
|
|
458
|
+
html += `<option value="${n}"${selected === n ? ' selected' : ''}>${n} (${f})</option>`;
|
|
459
|
+
return html;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function renderTimeline() {
|
|
463
|
+
$('timeline').innerHTML = '';
|
|
464
|
+
for (const act of S.config.acts)
|
|
465
|
+
$('timeline').appendChild(makeRow(act));
|
|
466
|
+
updateTotal();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function makeRow(act) {
|
|
470
|
+
const div = document.createElement('div');
|
|
471
|
+
div.className = `act-row act-type--${act.type}`;
|
|
472
|
+
div.dataset.id = act.id;
|
|
473
|
+
const isHold = act.type === 'hold';
|
|
474
|
+
const arrow = act.type === 'pingpong' ? '↔' : '→';
|
|
475
|
+
|
|
476
|
+
div.innerHTML = `
|
|
477
|
+
<select class="type-sel" title="Type">
|
|
478
|
+
<option value="act"${act.type==='act'?' selected':''}>act</option>
|
|
479
|
+
<option value="hold"${act.type==='hold'?' selected':''}>hold</option>
|
|
480
|
+
<option value="pingpong"${act.type==='pingpong'?' selected':''}>↔ ping</option>
|
|
481
|
+
<option value="freecamera"${act.type==='freecamera'?' selected':''}>⊕ free</option>
|
|
482
|
+
</select>
|
|
483
|
+
${isHold
|
|
484
|
+
? `<select class="frame-sel" title="Freeze at frame">
|
|
485
|
+
${markerOpts(act.frame, '— frame —')}
|
|
486
|
+
</select>`
|
|
487
|
+
: `<select class="from-sel" title="Start marker">
|
|
488
|
+
${markerOpts(act.from, '— start —')}
|
|
489
|
+
</select>
|
|
490
|
+
<span class="arrow">${arrow}</span>
|
|
491
|
+
<select class="to-sel" title="End marker">
|
|
492
|
+
${markerOpts(act.to, '— end —')}
|
|
493
|
+
</select>`
|
|
494
|
+
}
|
|
495
|
+
<div class="h-field" title="Drag to change height (vh) · Shift = slow">
|
|
496
|
+
<span class="h-val">${act.height}</span><span class="h-unit">vh</span>
|
|
497
|
+
</div>
|
|
498
|
+
<button class="del-btn" title="Remove">×</button>
|
|
499
|
+
`;
|
|
500
|
+
|
|
501
|
+
div.querySelector('.type-sel').addEventListener('change', e => changeType(act.id, e.target.value));
|
|
502
|
+
if (isHold) {
|
|
503
|
+
div.querySelector('.frame-sel').addEventListener('change', e => setField(act.id, 'frame', e.target.value));
|
|
504
|
+
} else {
|
|
505
|
+
div.querySelector('.from-sel').addEventListener('change', e => setField(act.id, 'from', e.target.value));
|
|
506
|
+
div.querySelector('.to-sel').addEventListener('change', e => setField(act.id, 'to', e.target.value));
|
|
507
|
+
}
|
|
508
|
+
div.querySelector('.del-btn').addEventListener('click', () => removeAct(act.id));
|
|
509
|
+
bindHeightDrag(div.querySelector('.h-field'), act.id);
|
|
510
|
+
|
|
511
|
+
return div;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ── Act CRUD ───────────────────────────────────────────────────────────────────
|
|
515
|
+
function addAct(type) {
|
|
516
|
+
const id = `act-${S._seq++}`;
|
|
517
|
+
const act = { id, type, height: 150 };
|
|
518
|
+
if (type === 'hold') {
|
|
519
|
+
act.frame = null;
|
|
520
|
+
} else {
|
|
521
|
+
act.from = type === 'pingpong' ? (S.markers['pingpong-start'] != null ? 'pingpong-start' : null)
|
|
522
|
+
: type === 'freecamera' ? (S.markers['freecamera-start'] != null ? 'freecamera-start' : null)
|
|
523
|
+
: null;
|
|
524
|
+
act.to = type === 'pingpong' ? (S.markers['pingpong-end'] != null ? 'pingpong-end' : null)
|
|
525
|
+
: type === 'freecamera' ? (S.markers['freecamera-end'] != null ? 'freecamera-end' : null)
|
|
526
|
+
: null;
|
|
527
|
+
}
|
|
528
|
+
S.config.acts.push(act);
|
|
529
|
+
$('timeline').appendChild(makeRow(act));
|
|
530
|
+
updateTotal();
|
|
531
|
+
setDirty(true);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function removeAct(id) {
|
|
535
|
+
S.config.acts = S.config.acts.filter(a => a.id !== id);
|
|
536
|
+
document.querySelector(`.act-row[data-id="${id}"]`)?.remove();
|
|
537
|
+
updateTotal();
|
|
538
|
+
setDirty(true);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function changeType(id, type) {
|
|
542
|
+
const act = S.config.acts.find(a => a.id === id);
|
|
543
|
+
if (!act) return;
|
|
544
|
+
const prev = act.type;
|
|
545
|
+
act.type = type;
|
|
546
|
+
|
|
547
|
+
if (type === 'hold') {
|
|
548
|
+
act.frame = act.from || null;
|
|
549
|
+
delete act.from; delete act.to;
|
|
550
|
+
} else {
|
|
551
|
+
if (prev === 'hold') { act.from = act.frame || null; delete act.frame; }
|
|
552
|
+
act.to = act.to ?? null;
|
|
553
|
+
if (type === 'pingpong') {
|
|
554
|
+
if (S.markers['pingpong-start'] != null) act.from = 'pingpong-start';
|
|
555
|
+
if (S.markers['pingpong-end'] != null) act.to = 'pingpong-end';
|
|
556
|
+
} else if (type === 'freecamera') {
|
|
557
|
+
if (S.markers['freecamera-start'] != null) act.from = 'freecamera-start';
|
|
558
|
+
if (S.markers['freecamera-end'] != null) act.to = 'freecamera-end';
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const row = document.querySelector(`.act-row[data-id="${id}"]`);
|
|
563
|
+
if (row) row.replaceWith(makeRow(act));
|
|
564
|
+
setDirty(true);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function setField(id, field, value) {
|
|
568
|
+
const act = S.config.acts.find(a => a.id === id);
|
|
569
|
+
if (act) { act[field] = value || null; setDirty(true); }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ── Height drag ────────────────────────────────────────────────────────────────
|
|
573
|
+
let _hd = null;
|
|
574
|
+
|
|
575
|
+
function bindHeightDrag(el, id) {
|
|
576
|
+
el.addEventListener('mousedown', e => {
|
|
577
|
+
e.preventDefault();
|
|
578
|
+
const act = S.config.acts.find(a => a.id === id);
|
|
579
|
+
if (!act) return;
|
|
580
|
+
_hd = { id, x0: e.clientX, h0: act.height, valEl: el.querySelector('.h-val') };
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
document.addEventListener('mousemove', e => {
|
|
585
|
+
if (!_hd) return;
|
|
586
|
+
const act = S.config.acts.find(a => a.id === _hd.id);
|
|
587
|
+
if (!act) return;
|
|
588
|
+
const spd = e.shiftKey ? 0.3 : 2;
|
|
589
|
+
act.height = Math.max(10, Math.round(_hd.h0 + (e.clientX - _hd.x0) * spd));
|
|
590
|
+
_hd.valEl.textContent = act.height;
|
|
591
|
+
updateTotal();
|
|
592
|
+
setDirty(true);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
document.addEventListener('mouseup', () => { _hd = null; });
|
|
596
|
+
|
|
597
|
+
// ── Keyboard shortcut ──────────────────────────────────────────────────────────
|
|
598
|
+
document.addEventListener('keydown', e => {
|
|
599
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave(); }
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// ── Init ───────────────────────────────────────────────────────────────────────
|
|
603
|
+
(async () => {
|
|
604
|
+
await apiCheck();
|
|
605
|
+
if (S.apiAvailable) {
|
|
606
|
+
await loadConfig();
|
|
607
|
+
} else {
|
|
608
|
+
setStatus('Server not running — open a config with Load or start fresh');
|
|
609
|
+
renderTimeline();
|
|
610
|
+
}
|
|
611
|
+
})();
|
|
612
|
+
</script>
|
|
613
|
+
</body>
|
|
614
|
+
</html>
|