holosplat 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +890 -0
- package/bin/holosplat.cjs +374 -0
- package/dist/holosplat.esm.js +766 -0
- package/dist/holosplat.esm.js.map +7 -0
- package/dist/holosplat.iife.js +766 -0
- package/dist/holosplat.iife.js.map +7 -0
- package/holosplat/editor.js +2947 -0
- package/holosplat/index.html +614 -0
- package/holosplat/stats.js +101 -0
- package/package.json +30 -0
- package/server.py +560 -0
- package/src/server.js +198 -0
package/server.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
HoloSplat local dev server.
|
|
4
|
+
|
|
5
|
+
- Serves the entire project with CORS headers so WebGPU can load scene files.
|
|
6
|
+
- Exposes a small /hs-api for the /holosplat editor to read and write files.
|
|
7
|
+
- Put .spz / .splat / .ply scene files in the scenes/ folder.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
python server.py [port] (default port: 8080)
|
|
11
|
+
|
|
12
|
+
Then open:
|
|
13
|
+
http://localhost:8080/examples/viewer.html
|
|
14
|
+
http://localhost:8080/holosplat/
|
|
15
|
+
|
|
16
|
+
Scene files are accessible at:
|
|
17
|
+
http://localhost:8080/scenes/your-file.spz
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import http.server
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import urllib.parse
|
|
27
|
+
|
|
28
|
+
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
|
|
29
|
+
|
|
30
|
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
SCENE_DIR = os.path.join(ROOT, 'scenes')
|
|
32
|
+
os.makedirs(SCENE_DIR, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
SCENE_EXTS = {'.spz', '.splat', '.ply', '.spzv'}
|
|
35
|
+
|
|
36
|
+
# Protects all read-modify-write operations on HTML/JS source files.
|
|
37
|
+
# ThreadingHTTPServer uses one thread per request; without a lock, two
|
|
38
|
+
# concurrent saves can interleave: one thread truncates the file while
|
|
39
|
+
# another reads it, receiving 0 bytes and writing them back.
|
|
40
|
+
_file_lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _set_js_config_line(html, remove_pattern, new_line, insert_patterns):
|
|
44
|
+
"""Replace a `player({...})` config property with `new_line`.
|
|
45
|
+
|
|
46
|
+
Removes every existing line matching `remove_pattern` first — this heals
|
|
47
|
+
duplicate lines left behind by earlier saves (previously only the first
|
|
48
|
+
match was ever updated, so stale duplicates accumulated across syncs) —
|
|
49
|
+
then inserts `new_line` once at the first matching location in
|
|
50
|
+
`insert_patterns` (regexes for "insert after this line", or starting with
|
|
51
|
+
`(\\n` to mean "insert before this match", used for the closing `});`).
|
|
52
|
+
"""
|
|
53
|
+
updated = re.sub(remove_pattern, '', html, flags=re.MULTILINE)
|
|
54
|
+
for pat in insert_patterns:
|
|
55
|
+
m = re.search(pat, updated)
|
|
56
|
+
if m:
|
|
57
|
+
ins = m.start() + 1 if pat.startswith(r'(\n') else m.end()
|
|
58
|
+
return updated[:ins] + new_line + '\n' + updated[ins:]
|
|
59
|
+
return updated
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
63
|
+
|
|
64
|
+
def end_headers(self):
|
|
65
|
+
self.send_header('Access-Control-Allow-Origin', '*')
|
|
66
|
+
self.send_header('Access-Control-Allow-Methods', 'GET, PUT, POST, OPTIONS')
|
|
67
|
+
self.send_header('Access-Control-Allow-Headers', 'Range, Content-Type')
|
|
68
|
+
self.send_header('Access-Control-Expose-Headers','Content-Length, Content-Range')
|
|
69
|
+
# Scene files (under /scenes/) only change via a manual re-export, so
|
|
70
|
+
# let the browser skip the network entirely on repeat loads instead
|
|
71
|
+
# of paying a revalidation round trip per file — that round trip,
|
|
72
|
+
# multiplied across a dozen+ parts plus color variants, is what made
|
|
73
|
+
# first loads visibly slower than cached repeat loads. Everything
|
|
74
|
+
# else (HTML/JS/editor API) stays no-cache so live edits show up
|
|
75
|
+
# immediately.
|
|
76
|
+
if urllib.parse.urlparse(self.path).path.startswith('/scenes/'):
|
|
77
|
+
self.send_header('Cache-Control', 'public, max-age=31536000, immutable')
|
|
78
|
+
else:
|
|
79
|
+
self.send_header('Cache-Control', 'no-cache')
|
|
80
|
+
super().end_headers()
|
|
81
|
+
|
|
82
|
+
def do_OPTIONS(self):
|
|
83
|
+
self.send_response(204)
|
|
84
|
+
self.end_headers()
|
|
85
|
+
|
|
86
|
+
def do_GET(self):
|
|
87
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
88
|
+
if parsed.path.startswith('/hs-api/'):
|
|
89
|
+
params = dict(urllib.parse.parse_qsl(parsed.query))
|
|
90
|
+
if parsed.path == '/hs-api/file': self._api_read(params.get('path', ''))
|
|
91
|
+
elif parsed.path == '/hs-api/ls': self._api_ls()
|
|
92
|
+
else: self._json(404, {'error': 'not found'})
|
|
93
|
+
else:
|
|
94
|
+
super().do_GET()
|
|
95
|
+
|
|
96
|
+
def do_PUT(self):
|
|
97
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
98
|
+
params = dict(urllib.parse.parse_qsl(parsed.query))
|
|
99
|
+
if parsed.path == '/hs-api/file':
|
|
100
|
+
self._api_write(params.get('path', ''))
|
|
101
|
+
else:
|
|
102
|
+
self._json(404, {'error': 'not found'})
|
|
103
|
+
|
|
104
|
+
def do_POST(self):
|
|
105
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
106
|
+
if parsed.path == '/hs-api/html-attr': self._api_html_attr()
|
|
107
|
+
elif parsed.path == '/hs-api/js-scenes': self._api_js_scenes()
|
|
108
|
+
elif parsed.path == '/hs-api/js-masks': self._api_js_masks()
|
|
109
|
+
elif parsed.path == '/hs-api/js-sh': self._api_js_sh()
|
|
110
|
+
elif parsed.path == '/hs-api/js-anim': self._api_js_anim()
|
|
111
|
+
elif parsed.path == '/hs-api/js-parts': self._api_js_parts()
|
|
112
|
+
elif parsed.path == '/hs-api/js-partsDir': self._api_js_partsDir()
|
|
113
|
+
elif parsed.path == '/hs-api/js-zIndex': self._api_js_zIndex()
|
|
114
|
+
elif parsed.path == '/hs-api/js-aaDilation': self._api_js_aaDilation()
|
|
115
|
+
elif parsed.path == '/hs-api/js-clips': self._api_js_clips()
|
|
116
|
+
else: self._json(404, {'error': 'not found'})
|
|
117
|
+
|
|
118
|
+
# ── Helpers ────────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
def _safe(self, rel):
|
|
121
|
+
"""Resolve rel to an absolute path that stays inside ROOT, or None."""
|
|
122
|
+
if not rel:
|
|
123
|
+
return None
|
|
124
|
+
full = os.path.realpath(os.path.join(ROOT, rel))
|
|
125
|
+
try:
|
|
126
|
+
if os.path.commonpath([ROOT, full]) != ROOT:
|
|
127
|
+
return None
|
|
128
|
+
except ValueError:
|
|
129
|
+
return None # different drives on Windows
|
|
130
|
+
if full == ROOT:
|
|
131
|
+
return None # can't read/write the project root itself
|
|
132
|
+
return full
|
|
133
|
+
|
|
134
|
+
def _json(self, code, data):
|
|
135
|
+
body = json.dumps(data).encode()
|
|
136
|
+
self.send_response(code)
|
|
137
|
+
self.send_header('Content-Type', 'application/json')
|
|
138
|
+
self.send_header('Content-Length', str(len(body)))
|
|
139
|
+
self.end_headers()
|
|
140
|
+
self.wfile.write(body)
|
|
141
|
+
|
|
142
|
+
def _api_read(self, rel):
|
|
143
|
+
path = self._safe(rel)
|
|
144
|
+
if not path or not os.path.isfile(path):
|
|
145
|
+
self._json(404, {'error': 'not found'})
|
|
146
|
+
return
|
|
147
|
+
with open(path, 'rb') as f:
|
|
148
|
+
body = f.read()
|
|
149
|
+
ext = os.path.splitext(rel)[1].lower()
|
|
150
|
+
ct = {
|
|
151
|
+
'.json': 'application/json',
|
|
152
|
+
'.html': 'text/html; charset=utf-8',
|
|
153
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
154
|
+
'.css': 'text/css; charset=utf-8',
|
|
155
|
+
}.get(ext, 'text/plain; charset=utf-8')
|
|
156
|
+
self.send_response(200)
|
|
157
|
+
self.send_header('Content-Type', ct)
|
|
158
|
+
self.send_header('Content-Length', str(len(body)))
|
|
159
|
+
self.end_headers()
|
|
160
|
+
self.wfile.write(body)
|
|
161
|
+
|
|
162
|
+
def _api_write(self, rel):
|
|
163
|
+
path = self._safe(rel)
|
|
164
|
+
if not path:
|
|
165
|
+
self._json(403, {'error': 'forbidden'})
|
|
166
|
+
return
|
|
167
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
168
|
+
body = self.rfile.read(length)
|
|
169
|
+
parent = os.path.dirname(path)
|
|
170
|
+
if parent:
|
|
171
|
+
os.makedirs(parent, exist_ok=True)
|
|
172
|
+
with _file_lock:
|
|
173
|
+
with open(path, 'wb') as f:
|
|
174
|
+
f.write(body)
|
|
175
|
+
self._json(200, {'ok': True})
|
|
176
|
+
|
|
177
|
+
def _read_body(self):
|
|
178
|
+
length = int(self.headers.get('Content-Length', 0))
|
|
179
|
+
return self.rfile.read(length).decode('utf-8')
|
|
180
|
+
|
|
181
|
+
def _api_html_attr(self):
|
|
182
|
+
try:
|
|
183
|
+
body = json.loads(self._read_body())
|
|
184
|
+
except Exception:
|
|
185
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
186
|
+
page = body.get('page', '')
|
|
187
|
+
el_id = body.get('id', '')
|
|
188
|
+
attrs = body.get('attrs', {})
|
|
189
|
+
if not page or not el_id or not attrs:
|
|
190
|
+
self._json(400, {'error': 'missing fields'}); return
|
|
191
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
192
|
+
if not full or not os.path.isfile(full):
|
|
193
|
+
self._json(404, {'error': 'not found'}); return
|
|
194
|
+
with _file_lock:
|
|
195
|
+
with open(full, encoding='utf-8') as f:
|
|
196
|
+
html = f.read()
|
|
197
|
+
id_pat = re.escape(el_id)
|
|
198
|
+
tag_rx = re.compile(rf'(<[a-zA-Z][^>]*?\s(?:id="{id_pat}")[^>]*?)(?=\s*>)', re.DOTALL)
|
|
199
|
+
m = tag_rx.search(html)
|
|
200
|
+
if not m:
|
|
201
|
+
self._json(404, {'error': f'element #{el_id} not found'}); return
|
|
202
|
+
tag = m.group(1)
|
|
203
|
+
for name, value in attrs.items():
|
|
204
|
+
n = re.escape(name)
|
|
205
|
+
tag = re.sub(rf'\s+{n}(?:="[^"]*"|=\'[^\']*\')?', '', tag)
|
|
206
|
+
if value is not None:
|
|
207
|
+
escaped = str(value).replace('"', '"')
|
|
208
|
+
tag += f' {name}="{escaped}"'
|
|
209
|
+
html = html[:m.start()] + tag + html[m.end():]
|
|
210
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
211
|
+
f.write(html)
|
|
212
|
+
self._json(200, {'ok': True})
|
|
213
|
+
|
|
214
|
+
def _api_js_sh(self):
|
|
215
|
+
try:
|
|
216
|
+
body = json.loads(self._read_body())
|
|
217
|
+
except Exception:
|
|
218
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
219
|
+
page = body.get('page', '')
|
|
220
|
+
sh = int(body.get('sh', 0))
|
|
221
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
222
|
+
if not full or not os.path.isfile(full):
|
|
223
|
+
self._json(404, {'error': 'not found'}); return
|
|
224
|
+
with _file_lock:
|
|
225
|
+
with open(full, encoding='utf-8') as f:
|
|
226
|
+
html = f.read()
|
|
227
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
228
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
229
|
+
new_line = f'{indent}sh: {sh}, // hs-sh'
|
|
230
|
+
updated = _set_js_config_line(
|
|
231
|
+
html,
|
|
232
|
+
r'^[ \t]*sh\s*:\s*\d+[^\n]*//\s*hs-sh[^\n]*\n?',
|
|
233
|
+
new_line,
|
|
234
|
+
[
|
|
235
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
236
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
237
|
+
]
|
|
238
|
+
)
|
|
239
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
240
|
+
f.write(updated)
|
|
241
|
+
self._json(200, {'ok': True})
|
|
242
|
+
|
|
243
|
+
def _api_js_anim(self):
|
|
244
|
+
try:
|
|
245
|
+
body = json.loads(self._read_body())
|
|
246
|
+
except Exception:
|
|
247
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
248
|
+
page = body.get('page', '')
|
|
249
|
+
url = body.get('url', '')
|
|
250
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
251
|
+
if not full or not os.path.isfile(full):
|
|
252
|
+
self._json(404, {'error': 'not found'}); return
|
|
253
|
+
with _file_lock:
|
|
254
|
+
with open(full, encoding='utf-8') as f:
|
|
255
|
+
html = f.read()
|
|
256
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
257
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
258
|
+
escaped = url.replace("'", "\\'")
|
|
259
|
+
new_line = f"{indent}animation: '{escaped}',"
|
|
260
|
+
updated = _set_js_config_line(
|
|
261
|
+
html,
|
|
262
|
+
r"^[ \t]*animation\s*:\s*(['\"])[^'\"]*\1[^\n]*\n?",
|
|
263
|
+
new_line,
|
|
264
|
+
[
|
|
265
|
+
r'([ \t]*flipY\s*:[^\n]*\n)',
|
|
266
|
+
r'([ \t]*scenes\s*:[^\n]*\n)',
|
|
267
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
268
|
+
]
|
|
269
|
+
)
|
|
270
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
271
|
+
f.write(updated)
|
|
272
|
+
self._json(200, {'ok': True})
|
|
273
|
+
|
|
274
|
+
def _api_js_parts(self):
|
|
275
|
+
try:
|
|
276
|
+
body = json.loads(self._read_body())
|
|
277
|
+
except Exception:
|
|
278
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
279
|
+
page = body.get('page', '')
|
|
280
|
+
parts = body.get('parts', {})
|
|
281
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
282
|
+
if not full or not os.path.isfile(full):
|
|
283
|
+
self._json(404, {'error': 'not found'}); return
|
|
284
|
+
with _file_lock:
|
|
285
|
+
with open(full, encoding='utf-8') as f:
|
|
286
|
+
html = f.read()
|
|
287
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
288
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
289
|
+
json_str = json.dumps(parts, ensure_ascii=False)
|
|
290
|
+
new_line = f'{indent}parts: {json_str}, // hs-parts'
|
|
291
|
+
# Replace existing parts block (single or multi-line)
|
|
292
|
+
updated = _set_js_config_line(
|
|
293
|
+
html,
|
|
294
|
+
r'^[ \t]*parts\s*:\s*\{[^}]*\}[^\n]*\n?',
|
|
295
|
+
new_line,
|
|
296
|
+
[
|
|
297
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
298
|
+
r'([ \t]*flipY\s*:[^\n]*\n)',
|
|
299
|
+
r'([ \t]*scenes\s*:[^\n]*\n)',
|
|
300
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
301
|
+
]
|
|
302
|
+
)
|
|
303
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
304
|
+
f.write(updated)
|
|
305
|
+
self._json(200, {'ok': True})
|
|
306
|
+
|
|
307
|
+
def _api_js_partsDir(self):
|
|
308
|
+
try:
|
|
309
|
+
body = json.loads(self._read_body())
|
|
310
|
+
except Exception:
|
|
311
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
312
|
+
page = body.get('page', '')
|
|
313
|
+
partsDir = body.get('partsDir', '')
|
|
314
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
315
|
+
if not full or not os.path.isfile(full):
|
|
316
|
+
self._json(404, {'error': 'not found'}); return
|
|
317
|
+
with _file_lock:
|
|
318
|
+
with open(full, encoding='utf-8') as f:
|
|
319
|
+
html = f.read()
|
|
320
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
321
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
322
|
+
escaped = partsDir.replace("'", "\\'")
|
|
323
|
+
new_line = f"{indent}partsDir: '{escaped}',"
|
|
324
|
+
updated = _set_js_config_line(
|
|
325
|
+
html,
|
|
326
|
+
r"^[ \t]*partsDir\s*:\s*(['\"])[^'\"]*\1[^\n]*\n?",
|
|
327
|
+
new_line,
|
|
328
|
+
[
|
|
329
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
330
|
+
r'([ \t]*flipY\s*:[^\n]*\n)',
|
|
331
|
+
r'([ \t]*scenes\s*:[^\n]*\n)',
|
|
332
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
333
|
+
]
|
|
334
|
+
)
|
|
335
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
336
|
+
f.write(updated)
|
|
337
|
+
self._json(200, {'ok': True})
|
|
338
|
+
|
|
339
|
+
def _api_js_zIndex(self):
|
|
340
|
+
try:
|
|
341
|
+
body = json.loads(self._read_body())
|
|
342
|
+
except Exception:
|
|
343
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
344
|
+
page = body.get('page', '')
|
|
345
|
+
zi = int(body.get('zIndex', 5))
|
|
346
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
347
|
+
if not full or not os.path.isfile(full):
|
|
348
|
+
self._json(404, {'error': 'not found'}); return
|
|
349
|
+
with _file_lock:
|
|
350
|
+
with open(full, encoding='utf-8') as f:
|
|
351
|
+
html = f.read()
|
|
352
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
353
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
354
|
+
new_line = f'{indent}zIndex: {zi}, // hs-zi'
|
|
355
|
+
updated = _set_js_config_line(
|
|
356
|
+
html,
|
|
357
|
+
r'^[ \t]*zIndex\s*:\s*-?\d+[^\n]*//\s*hs-zi[^\n]*\n?',
|
|
358
|
+
new_line,
|
|
359
|
+
[
|
|
360
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
361
|
+
r'([ \t]*flipY\s*:[^\n]*\n)',
|
|
362
|
+
r'([ \t]*scenes\s*:[^\n]*\n)',
|
|
363
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
364
|
+
]
|
|
365
|
+
)
|
|
366
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
367
|
+
f.write(updated)
|
|
368
|
+
self._json(200, {'ok': True})
|
|
369
|
+
|
|
370
|
+
def _api_js_aaDilation(self):
|
|
371
|
+
try:
|
|
372
|
+
body = json.loads(self._read_body())
|
|
373
|
+
except Exception:
|
|
374
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
375
|
+
page = body.get('page', '')
|
|
376
|
+
aa = round(float(body.get('aaDilation', 0.15)), 4)
|
|
377
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
378
|
+
if not full or not os.path.isfile(full):
|
|
379
|
+
self._json(404, {'error': 'not found'}); return
|
|
380
|
+
with _file_lock:
|
|
381
|
+
with open(full, encoding='utf-8') as f:
|
|
382
|
+
html = f.read()
|
|
383
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
384
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
385
|
+
new_line = f'{indent}aaDilation: {aa}, // hs-aa'
|
|
386
|
+
updated = _set_js_config_line(
|
|
387
|
+
html,
|
|
388
|
+
r'^[ \t]*aaDilation\s*:\s*[0-9.]+[^\n]*//\s*hs-aa[^\n]*\n?',
|
|
389
|
+
new_line,
|
|
390
|
+
[
|
|
391
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
392
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
393
|
+
]
|
|
394
|
+
)
|
|
395
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
396
|
+
f.write(updated)
|
|
397
|
+
self._json(200, {'ok': True})
|
|
398
|
+
|
|
399
|
+
def _api_js_scenes(self):
|
|
400
|
+
try:
|
|
401
|
+
body = json.loads(self._read_body())
|
|
402
|
+
except Exception:
|
|
403
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
404
|
+
page = body.get('page', '')
|
|
405
|
+
scenes = body.get('scenes', {})
|
|
406
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
407
|
+
if not full or not os.path.isfile(full):
|
|
408
|
+
self._json(404, {'error': 'not found'}); return
|
|
409
|
+
scenes_str = json.dumps(scenes, separators=(',', ':'))
|
|
410
|
+
with _file_lock:
|
|
411
|
+
with open(full, encoding='utf-8') as f:
|
|
412
|
+
html = f.read()
|
|
413
|
+
# Detect indentation from animation: line (or default 6 spaces)
|
|
414
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
415
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
416
|
+
new_line = f'{indent}scenes: {scenes_str}, // hs-scenes'
|
|
417
|
+
# Replace existing hs-scenes sentinel line
|
|
418
|
+
updated = _set_js_config_line(
|
|
419
|
+
html,
|
|
420
|
+
r'^[ \t]*scenes\s*:[^\n]*//\s*hs-scenes[^\n]*\n?',
|
|
421
|
+
new_line,
|
|
422
|
+
[
|
|
423
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
424
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
425
|
+
]
|
|
426
|
+
)
|
|
427
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
428
|
+
f.write(updated)
|
|
429
|
+
self._json(200, {'ok': True})
|
|
430
|
+
|
|
431
|
+
def _api_js_masks(self):
|
|
432
|
+
try:
|
|
433
|
+
body = json.loads(self._read_body())
|
|
434
|
+
except Exception:
|
|
435
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
436
|
+
page = body.get('page', '')
|
|
437
|
+
masks = body.get('masks', {})
|
|
438
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
439
|
+
if not full or not os.path.isfile(full):
|
|
440
|
+
self._json(404, {'error': 'not found'}); return
|
|
441
|
+
masks_str = json.dumps(masks, separators=(',', ':'))
|
|
442
|
+
with _file_lock:
|
|
443
|
+
with open(full, encoding='utf-8') as f:
|
|
444
|
+
html = f.read()
|
|
445
|
+
# Detect indentation from animation: line (or default 6 spaces)
|
|
446
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
447
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
448
|
+
new_line = f'{indent}masks: {masks_str}, // hs-masks'
|
|
449
|
+
# Replace existing hs-masks sentinel line
|
|
450
|
+
updated = _set_js_config_line(
|
|
451
|
+
html,
|
|
452
|
+
r'^[ \t]*masks\s*:[^\n]*//\s*hs-masks[^\n]*\n?',
|
|
453
|
+
new_line,
|
|
454
|
+
[
|
|
455
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
456
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
457
|
+
]
|
|
458
|
+
)
|
|
459
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
460
|
+
f.write(updated)
|
|
461
|
+
self._json(200, {'ok': True})
|
|
462
|
+
|
|
463
|
+
def _api_js_clips(self):
|
|
464
|
+
try:
|
|
465
|
+
body = json.loads(self._read_body())
|
|
466
|
+
except Exception:
|
|
467
|
+
self._json(400, {'error': 'invalid JSON'}); return
|
|
468
|
+
page = body.get('page', '')
|
|
469
|
+
clips = body.get('clips', [])
|
|
470
|
+
full = self._safe(page.lstrip('/').split('?')[0])
|
|
471
|
+
if not full or not os.path.isfile(full):
|
|
472
|
+
self._json(404, {'error': 'not found'}); return
|
|
473
|
+
clips_str = json.dumps(clips, separators=(',', ':'))
|
|
474
|
+
with _file_lock:
|
|
475
|
+
with open(full, encoding='utf-8') as f:
|
|
476
|
+
html = f.read()
|
|
477
|
+
m_indent = re.search(r'^([ \t]*)animation\s*:', html, re.MULTILINE)
|
|
478
|
+
indent = m_indent.group(1) if m_indent else ' '
|
|
479
|
+
new_line = f'{indent}clips: {clips_str}, // hs-clips'
|
|
480
|
+
updated = _set_js_config_line(
|
|
481
|
+
html,
|
|
482
|
+
r'^[ \t]*clips\s*:[^\n]*//\s*hs-clips[^\n]*\n?',
|
|
483
|
+
new_line,
|
|
484
|
+
[
|
|
485
|
+
r'([ \t]*animation\s*:[^\n]*\n)',
|
|
486
|
+
r'(\n[ \t]*\}\s*\)\s*;)',
|
|
487
|
+
]
|
|
488
|
+
)
|
|
489
|
+
with open(full, 'w', encoding='utf-8') as f:
|
|
490
|
+
f.write(updated)
|
|
491
|
+
self._json(200, {'ok': True})
|
|
492
|
+
|
|
493
|
+
def _api_ls(self):
|
|
494
|
+
result = {'spz': [], 'json': []}
|
|
495
|
+
|
|
496
|
+
# scenes/ folder (recursive — assets live in subfolders, e.g. scenes/headphones/)
|
|
497
|
+
if os.path.isdir(SCENE_DIR):
|
|
498
|
+
for dirpath, _dirnames, filenames in os.walk(SCENE_DIR):
|
|
499
|
+
for f in sorted(filenames):
|
|
500
|
+
ext = os.path.splitext(f)[1].lower()
|
|
501
|
+
rel = os.path.relpath(os.path.join(dirpath, f), ROOT).replace(os.sep, '/')
|
|
502
|
+
if ext in SCENE_EXTS: result['spz'].append(rel)
|
|
503
|
+
elif ext == '.json': result['json'].append(rel)
|
|
504
|
+
|
|
505
|
+
# blender/ folder
|
|
506
|
+
blender_dir = os.path.join(ROOT, 'blender')
|
|
507
|
+
if os.path.isdir(blender_dir):
|
|
508
|
+
for f in sorted(os.listdir(blender_dir)):
|
|
509
|
+
if f.endswith('.json'):
|
|
510
|
+
result['json'].append(f'blender/{f}')
|
|
511
|
+
|
|
512
|
+
# root-level JSON (configs, exports)
|
|
513
|
+
skip = {'package.json', 'package-lock.json'}
|
|
514
|
+
for f in sorted(os.listdir(ROOT)):
|
|
515
|
+
if f.endswith('.json') and f not in skip and os.path.isfile(os.path.join(ROOT, f)):
|
|
516
|
+
result['json'].append(f)
|
|
517
|
+
|
|
518
|
+
result['spz'].sort()
|
|
519
|
+
result['json'].sort()
|
|
520
|
+
self._json(200, result)
|
|
521
|
+
|
|
522
|
+
def log_message(self, fmt, *args):
|
|
523
|
+
path = args[0] if args else ''
|
|
524
|
+
code = args[1] if len(args) > 1 else ''
|
|
525
|
+
print(f' {code} {path}')
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# Serve from the project root
|
|
529
|
+
os.chdir(ROOT)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def list_scenes():
|
|
533
|
+
return [f for f in sorted(os.listdir(SCENE_DIR))
|
|
534
|
+
if os.path.splitext(f)[1].lower() in SCENE_EXTS]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
print()
|
|
538
|
+
print(' HoloSplat Dev Server')
|
|
539
|
+
print(' ' + '=' * 40)
|
|
540
|
+
print(f' Examples: http://localhost:{PORT}/examples/viewer.html')
|
|
541
|
+
print(f' Editor: http://localhost:{PORT}/holosplat/')
|
|
542
|
+
print(f' Scenes: http://localhost:{PORT}/scenes/')
|
|
543
|
+
print()
|
|
544
|
+
|
|
545
|
+
scenes = list_scenes()
|
|
546
|
+
if scenes:
|
|
547
|
+
print(' Scene files found:')
|
|
548
|
+
for f in scenes:
|
|
549
|
+
print(f' http://localhost:{PORT}/scenes/{urllib.parse.quote(f)}')
|
|
550
|
+
else:
|
|
551
|
+
print(' No scene files yet — drop .spz / .splat / .ply files into scenes/')
|
|
552
|
+
print()
|
|
553
|
+
print(' Press Ctrl+C to stop.')
|
|
554
|
+
print()
|
|
555
|
+
|
|
556
|
+
with http.server.ThreadingHTTPServer(('', PORT), Handler) as httpd:
|
|
557
|
+
try:
|
|
558
|
+
httpd.serve_forever()
|
|
559
|
+
except KeyboardInterrupt:
|
|
560
|
+
print('\n Server stopped.')
|