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/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('"', '&quot;')
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.')