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,374 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const pkg = path.join(__dirname, '..'); // root of the installed package
8
+ const cwd = process.cwd(); // target project root
9
+ const args = process.argv.slice(2);
10
+ const cmd = args[0];
11
+ const flags = new Set(args.slice(1));
12
+
13
+ // ── init ──────────────────────────────────────────────────────────────────────
14
+ if (cmd === 'init') {
15
+ const withServer = flags.has('--with-server');
16
+ const noServer = flags.has('--no-server');
17
+
18
+ // Detect project type from package.json
19
+ const projectPkg = readPkgJson(cwd);
20
+ const framework = detectFramework(projectPkg);
21
+ const hasOwnServer = noServer ? false
22
+ : withServer ? false
23
+ : (framework !== 'none');
24
+
25
+ // ── holosplat/ editor ──────────────────────────────────────────────────────
26
+ const editorDir = path.join(cwd, 'holosplat');
27
+ if (!fs.existsSync(editorDir)) fs.mkdirSync(editorDir, { recursive: true });
28
+
29
+ const html = fs.readFileSync(path.join(pkg, 'holosplat', 'index.html'), 'utf8')
30
+ .replace('../dist/holosplat.iife.js', './holosplat.iife.js');
31
+ fs.writeFileSync(path.join(editorDir, 'index.html'), html);
32
+
33
+ fs.copyFileSync(
34
+ path.join(pkg, 'dist', 'holosplat.iife.js'),
35
+ path.join(editorDir, 'holosplat.iife.js')
36
+ );
37
+
38
+ // ── scenes/ ───────────────────────────────────────────────────────────────
39
+ const scenesDir = path.join(cwd, 'scenes');
40
+ if (!fs.existsSync(scenesDir)) {
41
+ fs.mkdirSync(scenesDir, { recursive: true });
42
+ fs.writeFileSync(path.join(scenesDir, '.gitkeep'), '');
43
+ }
44
+
45
+ // ── .gitignore ────────────────────────────────────────────────────────────
46
+ const giPath = path.join(cwd, '.gitignore');
47
+ const giLine = 'holosplat/holosplat.iife.js';
48
+ if (fs.existsSync(giPath)) {
49
+ const gi = fs.readFileSync(giPath, 'utf8');
50
+ if (!gi.includes(giLine))
51
+ fs.appendFileSync(giPath, '\n# HoloSplat editor runtime\n' + giLine + '\n');
52
+ }
53
+
54
+ // ── Server setup ──────────────────────────────────────────────────────────
55
+ if (!hasOwnServer) {
56
+ const dst = path.join(cwd, 'server.py');
57
+ if (!fs.existsSync(dst)) {
58
+ fs.copyFileSync(path.join(pkg, 'server.py'), dst);
59
+ console.log(' server.py ← python server.py to start');
60
+ } else {
61
+ console.log(' (server.py already exists — skipped)');
62
+ }
63
+ } else {
64
+ writeServerSnippet(editorDir, framework);
65
+ console.log(' holosplat/server-snippet.js ← add these routes to your server');
66
+ }
67
+
68
+ // ── CLAUDE.md ─────────────────────────────────────────────────────────────
69
+ writeClaudeMd(cwd, framework, hasOwnServer);
70
+ console.log(' CLAUDE.md ← HoloSplat section added/created');
71
+
72
+ // ── Done ──────────────────────────────────────────────────────────────────
73
+ console.log('\n HoloSplat initialised\n');
74
+ console.log(' holosplat/index.html ← art direction editor');
75
+ console.log(' holosplat/holosplat.iife.js ← editor runtime (gitignored)');
76
+ console.log(' scenes/ ← drop .spz / .ply / .splat files here');
77
+
78
+ if (!hasOwnServer) {
79
+ console.log('\n Start editing:');
80
+ console.log(' python server.py');
81
+ console.log(' open http://localhost:8080/holosplat/\n');
82
+ } else {
83
+ console.log('\n Next steps:');
84
+ console.log(' 1. Add routes from holosplat/server-snippet.js to your server');
85
+ console.log(' 2. Start your dev server');
86
+ console.log(' 3. Open http://localhost:<port>/holosplat/\n');
87
+ }
88
+
89
+ // ── upgrade ───────────────────────────────────────────────────────────────────
90
+ } else if (cmd === 'upgrade') {
91
+ const editorDir = path.join(cwd, 'holosplat');
92
+ if (!fs.existsSync(editorDir)) {
93
+ console.error('\n Run `npx holosplat init` first.\n');
94
+ process.exit(1);
95
+ }
96
+
97
+ const html = fs.readFileSync(path.join(pkg, 'holosplat', 'index.html'), 'utf8')
98
+ .replace('../dist/holosplat.iife.js', './holosplat.iife.js');
99
+ fs.writeFileSync(path.join(editorDir, 'index.html'), html);
100
+ fs.copyFileSync(
101
+ path.join(pkg, 'dist', 'holosplat.iife.js'),
102
+ path.join(editorDir, 'holosplat.iife.js')
103
+ );
104
+
105
+ const serverDst = path.join(cwd, 'server.py');
106
+ if (fs.existsSync(serverDst))
107
+ fs.copyFileSync(path.join(pkg, 'server.py'), serverDst);
108
+
109
+ const snippetDst = path.join(cwd, 'holosplat', 'server-snippet.js');
110
+ if (fs.existsSync(snippetDst))
111
+ writeServerSnippet(path.join(cwd, 'holosplat'), detectFramework(readPkgJson(cwd)));
112
+
113
+ const version = require(path.join(pkg, 'package.json')).version;
114
+ console.log(`\n HoloSplat upgraded to v${version}\n`);
115
+
116
+ // ── help ──────────────────────────────────────────────────────────────────────
117
+ } else {
118
+ const version = require(path.join(pkg, 'package.json')).version;
119
+ console.log(`
120
+ HoloSplat CLI v${version}
121
+
122
+ npx holosplat init Set up the editor in this project
123
+ npx holosplat init --with-server Also copy server.py (Python dev server)
124
+ npx holosplat init --no-server Skip server setup entirely
125
+ npx holosplat upgrade Refresh editor files after updating the package
126
+ `);
127
+ }
128
+
129
+ // ── helpers ───────────────────────────────────────────────────────────────────
130
+
131
+ function readPkgJson(dir) {
132
+ try { return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); }
133
+ catch { return {}; }
134
+ }
135
+
136
+ function detectFramework(pkg) {
137
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
138
+ if (deps.next) return 'nextjs';
139
+ if (deps.vite) return 'vite';
140
+ if (deps.express) return 'express';
141
+ if (pkg.scripts?.dev || pkg.scripts?.start) return 'generic';
142
+ return 'none';
143
+ }
144
+
145
+ function writeServerSnippet(dir, framework) {
146
+ const snippets = {
147
+ express: `// Express / Connect
148
+ import { createHsApiHandler } from 'holosplat/server';
149
+ app.use('/hs-api', createHsApiHandler());
150
+ `,
151
+ vite: `// Vite (vite.config.js)
152
+ import { createHsApiHandler } from 'holosplat/server';
153
+ export default defineConfig({
154
+ plugins: [{
155
+ name: 'holosplat',
156
+ configureServer(server) {
157
+ server.middlewares.use('/hs-api', createHsApiHandler());
158
+ },
159
+ }],
160
+ });
161
+ `,
162
+ nextjs: `// Next.js — pages router (pages/api/hs-api/[...route].js)
163
+ import { createHsApiHandler } from 'holosplat/server';
164
+ const handler = createHsApiHandler();
165
+ export default function hsApi(req, res) {
166
+ const sub = '/' + (req.query.route ?? []).join('/');
167
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
168
+ req.url = sub + qs;
169
+ handler(req, res);
170
+ }
171
+ export const config = { api: { bodyParser: false } };
172
+ `,
173
+ generic: `// Generic Node.js (mount at /hs-api in your server)
174
+ import { createHsApiHandler } from 'holosplat/server';
175
+ const hsApi = createHsApiHandler();
176
+ // Express: app.use('/hs-api', hsApi);
177
+ // Vite: server.middlewares.use('/hs-api', hsApi);
178
+ // Raw http: if (req.url.startsWith('/hs-api')) { req.url = req.url.slice(7); hsApi(req, res); }
179
+ `,
180
+ };
181
+
182
+ const body = `// HoloSplat API routes — add to your existing server
183
+ // Generated by: npx holosplat init
184
+ //
185
+ // The /holosplat editor needs GET /hs-api/ls, GET /hs-api/file, PUT /hs-api/file
186
+ // to read and write project files (hs-config.json, scene listings).
187
+
188
+ ${snippets[framework] || snippets.generic}
189
+ // ── All frameworks ──────────────────────────────────────────────────────────
190
+ // Express / Connect: app.use('/hs-api', createHsApiHandler())
191
+ // Vite: server.middlewares.use('/hs-api', createHsApiHandler())
192
+ // Next.js pages: see pages/api/hs-api/[...route].js above
193
+ `;
194
+ fs.writeFileSync(path.join(dir, 'server-snippet.js'), body);
195
+ }
196
+
197
+ function writeClaudeMd(dir, framework, hasOwnServer) {
198
+ const claudePath = path.join(dir, 'CLAUDE.md');
199
+ const marker = '<!-- holosplat -->';
200
+
201
+ const serverSection = hasOwnServer
202
+ ? serverInstructions(framework)
203
+ : `### Dev server
204
+ Run \`python server.py\` — serves the project and mounts \`/hs-api\` automatically.
205
+ The editor is at \`http://localhost:8080/holosplat/\`.`;
206
+
207
+ const section = `
208
+ ${marker}
209
+ ## HoloSplat
210
+
211
+ Installed as \`holosplat\` npm package. WebGPU Gaussian Splat viewer with scroll-driven animation.
212
+
213
+ ### Import (ESM / bundler — no script tag needed)
214
+ \`\`\`js
215
+ import { Viewer, player, scrollScene } from 'holosplat';
216
+ \`\`\`
217
+
218
+ ${serverSection}
219
+
220
+ ### Single embed (auto-init via data attribute)
221
+ \`\`\`html
222
+ <div data-holosplat="/scenes/scene.spz"
223
+ data-holosplat-anim="/scenes/anim.json"
224
+ style="width:100%;height:500px"></div>
225
+ \`\`\`
226
+
227
+ ### Scroll-driven scene (HTML structure)
228
+ \`\`\`html
229
+ <div class="hs-scene">
230
+ <!-- sticky canvas -->
231
+ <div class="hs-stage"
232
+ data-holosplat="/scenes/scene.spz"
233
+ data-holosplat-anim="/scenes/anim.json"></div>
234
+
235
+ <!-- scroll track — each child maps to a Blender marker range -->
236
+ <div class="hs-track">
237
+ <div class="hs-act" data-from="intro" data-to="next" style="height:200vh"></div>
238
+ <div class="hs-hold" data-frame="next" style="height:100vh"></div>
239
+ <div class="hs-act" data-from="next" data-to="end" style="height:300vh"></div>
240
+ </div>
241
+ </div>
242
+ \`\`\`
243
+ - \`data-from\` / \`data-to\` / \`data-frame\` accept Blender marker names or frame numbers.
244
+ - Height (vh) controls how many scroll pixels the animation takes — taller = slower.
245
+ - Special act types: \`data-from="pingpong-start"\` (auto-loop) and \`data-from="freecamera-start"\` (free orbit).
246
+
247
+ ### hs-config.json (managed by the /holosplat editor, do not hand-edit)
248
+ The editor writes this file. At runtime, a loader reads it and populates the
249
+ \`.hs-track\` div with the correct \`data-from\`/\`data-to\`/\`style="height"\` attributes.
250
+ \`\`\`json
251
+ {
252
+ "version": 1,
253
+ "scene": "scenes/scene.spz",
254
+ "animation": "scenes/anim.json",
255
+ "acts": [
256
+ { "id": "intro", "type": "act", "from": "intro", "to": "marker2", "height": 200 },
257
+ { "id": "loop", "type": "pingpong", "from": "pingpong-start", "to": "pingpong-end", "height": 150 }
258
+ ]
259
+ }
260
+ \`\`\`
261
+ \`type\` is one of \`act\` | \`pingpong\` | \`freecamera\` | \`hold\`. \`from\`/\`to\`/\`frame\` reference Blender timeline marker names.
262
+
263
+ ### player(container, opts) — full option reference
264
+ \`\`\`js
265
+ const api = player('#scene', {
266
+ scene: '/scenes/scene.spz', // or 'parts' for a multi-file scene
267
+ animation: '/scenes/anim.json', // Blender-exported camera/markers/state timeline
268
+ clips: ['/scenes/asset-rig.json'], // string or array — product-customization assets, see below
269
+ partsDir: '/scenes/parts', // base dir clip parts resolve against
270
+ scenes: { // markerName → per-scene playback config
271
+ intro: { linkedId: 'intro', playback: 'auto', playOnce: true },
272
+ hero: { linkedId: 'hero', waitForTimeline: true, pingpong: true, blendOut: 46, pan: { enabled: true } },
273
+ },
274
+ masks: { partName: { feather: 0.3 } }, // mask-volume soft-edge overrides, by name
275
+ sh: 3, // global spherical-harmonics degree (0-3); omit to use per-scene/device-tier default
276
+ aaDilation: 0.3, // anti-aliasing covariance dilation
277
+ gpuSort: false, // opt-in GPU compute-shader radix sort
278
+ quality: 'auto', // 'auto'|'low'|'medium'|'high' — device-tier presets
279
+ flipY: false, splatScale: 1.08, autoRotate: false, background: 'transparent',
280
+ fov: 60, near: 0.01, far: 2000,
281
+ onLoad, onProgress, onError,
282
+ });
283
+ \`\`\`
284
+
285
+ ### Runtime API (returned by player())
286
+ \`\`\`js
287
+ api.playClip(clipId) // trigger a "<product>-<variant>" clip — radio-group per product, see clips below
288
+ api.playVariant(axis, value) // crossfade an axis-transition (e.g. color swap): api.playVariant('color', 'blue')
289
+ api.playState(axis, value) // seek a continuous state timeline (e.g. fold/unfold, open/close a lid)
290
+ api.setVariant(partId, name) // instant per-part variant swap (palette/geometry), no animation
291
+ api.getVariants(partId) // list variant names available for a part
292
+ api.setMaskFeather(name, value) // override a mask volume's soft-edge falloff at runtime
293
+ api.setSplatScale(s) / setAutoRotate(v) / setFlipY(v) / setShDegree(n) / setAaDilation(v)
294
+ api.setAnimationPaused(v) / setCameraFree(v) / resetCamera()
295
+ api.loadClips(url, opts) / api.unloadClips(ids) // load/remove asset clip files after init
296
+ api.destroy()
297
+ \`\`\`
298
+
299
+ ### Clips, axis transitions, and states (product customization)
300
+ These come from asset rig files (\`*-rig.json\`) — exported by the Blender pipeline in
301
+ the HoloSplat repo, never hand-written. A rig file's \`clips\`/\`transitions\`/\`states\`
302
+ map to the three playback primitives above:
303
+ - **clips** — one-shot in/hold/out triggers, grouped by product (\`playClip\`)
304
+ - **transitions** — two-value crossfades on a shared axis, e.g. color swatches (\`playVariant\`)
305
+ - **states** — a continuous per-axis timeline with named markers, e.g. fold/unfold, lid open/closed
306
+ (\`playState\`); also auto-driven by \`"state: <asset>.<axis>=<value>"\` markers in the main
307
+ animation timeline as the user scrolls, so it usually needs no button at all.
308
+
309
+ ### Rules
310
+ - Never import from \`holosplat/server\` in browser/client code — it is Node.js only.
311
+ - Never deploy \`holosplat/\` — it is in \`.vercelignore\` and must stay local-only.
312
+ - \`holosplat/holosplat.iife.js\` is gitignored — it is a build copy for the editor preview.
313
+ - Scene files (\`.spz\`, \`.ply\`, \`.splat\`) go in \`scenes/\` or \`public/scenes/\`. Prefer \`.spz\`.
314
+ - \`hs-config.json\` and \`*-rig.json\` files are generated by the editor/Blender export — treat as data, don't hand-edit.
315
+ <!-- /holosplat -->
316
+ `;
317
+
318
+ if (fs.existsSync(claudePath)) {
319
+ let existing = fs.readFileSync(claudePath, 'utf8');
320
+ // Replace existing HoloSplat block if present, otherwise append
321
+ const re = new RegExp(`\n${marker}[\\s\\S]*?<!-- /holosplat -->`, 'g');
322
+ if (re.test(existing)) {
323
+ existing = existing.replace(re, section.trimEnd());
324
+ fs.writeFileSync(claudePath, existing);
325
+ } else {
326
+ fs.appendFileSync(claudePath, section);
327
+ }
328
+ } else {
329
+ fs.writeFileSync(claudePath, `# Project\n\nAdd project-specific notes here.\n${section}`);
330
+ }
331
+ }
332
+
333
+ function serverInstructions(framework) {
334
+ const examples = {
335
+ vite: `### Server API (Vite — vite.config.js)
336
+ \`\`\`js
337
+ import { createHsApiHandler } from 'holosplat/server';
338
+ export default defineConfig({
339
+ plugins: [{
340
+ name: 'holosplat',
341
+ configureServer(server) {
342
+ server.middlewares.use('/hs-api', createHsApiHandler());
343
+ },
344
+ }],
345
+ });
346
+ \`\`\``,
347
+ express: `### Server API (Express)
348
+ \`\`\`js
349
+ import { createHsApiHandler } from 'holosplat/server';
350
+ app.use('/hs-api', createHsApiHandler());
351
+ \`\`\``,
352
+ nextjs: `### Server API (Next.js — pages/api/hs-api/[...route].js)
353
+ \`\`\`js
354
+ import { createHsApiHandler } from 'holosplat/server';
355
+ const handler = createHsApiHandler();
356
+ export default function hsApi(req, res) {
357
+ const sub = '/' + (req.query.route ?? []).join('/');
358
+ const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
359
+ req.url = sub + qs;
360
+ handler(req, res);
361
+ }
362
+ export const config = { api: { bodyParser: false } };
363
+ \`\`\``,
364
+ generic: `### Server API
365
+ \`\`\`js
366
+ import { createHsApiHandler } from 'holosplat/server';
367
+ // Mount at /hs-api in your server:
368
+ app.use('/hs-api', createHsApiHandler()); // Express/Connect
369
+ // or: server.middlewares.use('/hs-api', createHsApiHandler()); // Vite
370
+ \`\`\``,
371
+ };
372
+ return (examples[framework] || examples.generic) +
373
+ '\nThis is required for the `/holosplat` editor to read and write files. **Only enable in development.**';
374
+ }