greenlight-motion 0.3.4 → 0.5.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/NOTICE CHANGED
@@ -19,5 +19,9 @@ Files the engine downloads from the GreenLight CDN when a project uses them (sou
19
19
  lighting environments) are not part of this software and are not covered by the Apache License; see
20
20
  legal/THIRD_PARTY_ASSETS.md and legal/README.md.
21
21
 
22
+ The 3D Scene engine is not part of this software: it is the 3D plugin, the separate package greenlight-motion-3d, which
23
+ the engine downloads only when a project has a 3D Scene (or a page asks for it). It has its own licence (use yes; no
24
+ copying, redistribution, modification or competing products) and is not covered by the Apache License.
25
+
22
26
  "GreenLight", "GreenLight Dash", "GLEA" and "GreenLight Motion" are trademarks of WPSOUL. The Apache
23
27
  License does not grant permission to use them; see legal/TRADEMARKS.md.
package/README.md CHANGED
@@ -17,16 +17,16 @@ The agent writes the video as data: a `project.json` and its files. The engine t
17
17
  ## Quick start
18
18
 
19
19
  ```bash
20
- npx greenlight-motion@0.3.4 doctor # Node 18+, Chrome or Edge, WebGPU, H.264: what's missing and how to fix it
21
- npx greenlight-motion@0.3.4 init my-video # a working starter: index.html, project.json, assets/
20
+ npx greenlight-motion@0.5.0 doctor # Node 18+, Chrome or Edge, WebGPU, H.264: what's missing and how to fix it
21
+ npx greenlight-motion@0.5.0 init my-video # a working starter: index.html, project.json, assets/
22
22
  # edit my-video/project.json, put pictures / video / sound in my-video/assets/
23
23
  # or: write my-video/script.json (a scene script, skill/references/scene-script.md), then
24
- npx greenlight-motion@0.3.4 script my-video # script.json → project.json with the Video Editor's own presets
25
- npx greenlight-motion@0.3.4 check my-video # what to fix: missing files, template mistakes, blank frames, broken plugins
26
- npx greenlight-motion@0.3.4 stills my-video # frames + a labelled contact sheet, to look at
27
- npx greenlight-motion@0.3.4 render my-video --fps 60 --motion-blur
28
- npx greenlight-motion@0.3.4 pack my-video # everything in ONE html file
29
- npx greenlight-motion@0.3.4 pack my-video --glvideo # ONE .glvideo: double-clicked, it opens in GreenLight Dash
24
+ npx greenlight-motion@0.5.0 script my-video # script.json → project.json with the Video Editor's own presets
25
+ npx greenlight-motion@0.5.0 check my-video # what to fix: missing files, template mistakes, blank frames, broken plugins
26
+ npx greenlight-motion@0.5.0 stills my-video # frames + a labelled contact sheet, to look at
27
+ npx greenlight-motion@0.5.0 render my-video --fps 60 --motion-blur
28
+ npx greenlight-motion@0.5.0 pack my-video # everything in ONE html file
29
+ npx greenlight-motion@0.5.0 pack my-video --glvideo # ONE .glvideo: double-clicked, it opens in GreenLight Dash
30
30
  ```
31
31
 
32
32
  Also: `trace` (every animated layer's values frame by frame, plus the layer's moments), `set key=value`
@@ -72,7 +72,7 @@ The engine is one classic script, so it runs from a double-clicked file, a CDN o
72
72
 
73
73
  ```html
74
74
  <div id="app"></div>
75
- <script src="https://cdn.jsdelivr.net/npm/greenlight-motion@0.3.4/dist/greenlight-motion.js"></script>
75
+ <script src="https://cdn.jsdelivr.net/npm/greenlight-motion@0.5.0/dist/greenlight-motion.js"></script>
76
76
  <script>
77
77
  GreenLightMotion.mountStudio(document.getElementById('app'), {
78
78
  project: { /* project.json */ },
@@ -87,7 +87,12 @@ Its review (notes with drawings, highlights, changes) is text to copy for your a
87
87
  (`config.save`). In a narrow frame — a chat's column, a card — pass `chat: { height, full, onExpand }`: up to 900 px
88
88
  wide it becomes the mobile editor in a box `height` px tall (the whole frame with `full: true`), the timeline across the
89
89
  width with its panels as sheets over it; wider, the full studio. `onExpand(full)` puts a Full screen button in the app
90
- bar for a host that can go full screen; the returned studio's `chat.set({ height, full })` follows the host.
90
+ bar for a host that can go full screen; the returned studio's `chat.set({ height, full })` follows the host. The mobile
91
+ editor has Undo / Redo in its app bar and Go to start / end in its play row. On a touch device the page keeps memory low
92
+ (a preview at most 960 px long, pictures at the size they are drawn, only the scene on screen and the next held), renders
93
+ the seconds a phone plays too slowly into a cache of its own (a line over the ruler, a status beside the time; the files
94
+ stay in the browser), and asks before the first Play of a film too heavy for a phone. The page's `cache` API and
95
+ `memoryStats()` read all of it.
91
96
  `GreenLightMotion.mountPage(el, { project, assets, theme })` is the plain page under it, without the review interface.
92
97
 
93
98
  Without the page UI (in the browser, through a bundler or the ESM build; Node runs only the CLI):
@@ -103,6 +108,34 @@ const { blob } = await engine.export({ format: 'mp4', fps: 60 });
103
108
  In a Claude artifact a page can't start a download by itself, so the page saves the MP4 through the
104
109
  artifact's `downloads` capability.
105
110
 
111
+ **A 3D Scene** (a layer with `rig.scene`, a scene script's `world`) is drawn by the **3D plugin**, a package of its own:
112
+ [`greenlight-motion-3d`](https://www.npmjs.com/package/greenlight-motion-3d) (GreenLight Dash's 3D engine: live Draft and
113
+ path-traced Final; not open source, see its licence). You never install it by hand:
114
+
115
+ - **In a browser** the engine fetches it from jsDelivr the first time a project has a Scene: the newest release of the range
116
+ this engine takes (`peerDependencies`), read from its `manifest.json` and then pinned to that exact release. A 2D video
117
+ never loads a byte of it. A page that serves the plugin's `dist/world3d/` itself names the folder before mounting:
118
+
119
+ ```js
120
+ GreenLightMotion.setWorldBase('https://cdn.jsdelivr.net/npm/greenlight-motion-3d@0.1.0/dist/world3d/'); // or your own copy
121
+ await GreenLightMotion.world3d.load(); // a "Download 3D plugin" button: get it before any Scene
122
+ GreenLightMotion.world3d.status(); // { state: 'idle' | 'loading' | 'ready' | 'failed', version, bytes, error }
123
+ const { png, hints, objects, lights } = await engine.critique(2); // the frame at 2 s and what its shot shows, with hints
124
+ ```
125
+
126
+ - **The CLI** downloads it once into this computer's cache (`~/Library/Caches/greenlight-motion/` on a Mac,
127
+ `%LOCALAPPDATA%\greenlight-motion\Cache` on Windows, `~/.cache/greenlight-motion` on Linux; `GREENLIGHT_MOTION_3D_CACHE`
128
+ moves it) the first time a folder has a Scene, checks it against the registry's sha512 and copies its `dist/world3d/` beside
129
+ the page. `npx greenlight-motion add-3d` gets it ahead; `doctor` says which release is there. Offline:
130
+ `GREENLIGHT_MOTION_3D=<an unpacked copy or its .tgz>`; an installed `greenlight-motion-3d` next to the project is used as it is.
131
+
132
+ Its files load as modules, so a page that carries them in its own folder runs from a server (the CLI's `review` serves one),
133
+ not from a double-clicked file.
134
+
135
+ **Final in a page you host:** `GreenLightMotion.setFinalCap({ seconds: 10, spp: 64 })` makes a Final (path-traced) render costlier
136
+ than 10 s at 64 samples per pixel (1080p, 30 fps) wait for a second press of Render (or `page.renderVideo({ confirmLongFinal:
137
+ true })`); the GreenLight Motion connector sets that. No cap is the default; `GreenLightMotion.finalCost(project)` says what a render costs.
138
+
106
139
  ## Requirements
107
140
 
108
141
  - **Viewing and rendering:** desktop Chrome or Edge with WebGPU (Chrome 113+).
@@ -1,6 +1,6 @@
1
1
  # Third-party licences
2
2
 
3
- The engine bundle (`dist/greenlight-motion.js`, `dist/greenlight-motion.mjs`) and the scene script (`dist/greenlight-motion-script.mjs`)
3
+ The engine bundle (`dist/greenlight-motion.js`, `dist/greenlight-motion.mjs`), the scene script (`dist/greenlight-motion-script.mjs`)
4
4
  include code from these packages.
5
5
  Generated by `scripts/prepare.mjs` from the build. Files on the GreenLight CDN are listed in
6
6
  [legal/THIRD_PARTY_ASSETS.md](legal/THIRD_PARTY_ASSETS.md); artwork credits are in [NOTICE](NOTICE).
package/cli/index.mjs CHANGED
@@ -15,6 +15,9 @@
15
15
  // npx greenlight-motion trace <dir> [--fps 30] [--items a,b] [--out f.json] every keyframed layer's values per frame + its moments
16
16
  // npx greenlight-motion review <dir> [--port 4810] [--open] the page for the user's review: the notes they mark on it land in <dir>/review/
17
17
  // npx greenlight-motion schema [topic] the project format (format | template | effects | transitions | easings | paths | expressions | sounds)
18
+ // npx greenlight-motion add-3d [--json] the 3D plugin (greenlight-motion-3d) into this computer's cache now (doctor says what is there)
19
+ // A 3D Scene (a script's `world`) gets the 3D plugin by itself the first time (downloaded once); its folder plays from a local server
20
+ // (review), and check / stills / render serve it themselves.
18
21
  //
19
22
  // Needs Node 18+ and Chrome or Edge (the engine draws with WebGPU; a headless *shell* has no WebGPU, so the CLI
20
23
  // drives the real browser in its new headless mode). No ffmpeg: the page encodes the MP4 itself.
@@ -26,11 +29,38 @@ import path from 'node:path';
26
29
  import { fileURLToPath, pathToFileURL } from 'node:url';
27
30
  import { applyEdits } from './apply.mjs';
28
31
  import { glvideoBytes } from './glvideo.mjs';
32
+ import { find3d, cacheRoot, cached, NAME as NAME3D } from './plugin3d.mjs';
29
33
 
30
34
  const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
35
  const require = createRequire(import.meta.url);
32
36
  const ENGINE = path.join(PKG, 'dist', 'greenlight-motion.js');
33
- const VERSION = JSON.parse(readFileSync(path.join(PKG, 'package.json'), 'utf8')).version;
37
+ // A 3D Scene (a Scene layer: rig.renderer "world", a script's `world`): its 3D engine is the 3D plugin, a package of its own
38
+ // (greenlight-motion-3d, at the release range this engine takes: package.json peerDependencies). The CLI finds it, or downloads it
39
+ // once into this computer's cache (cli/plugin3d.mjs), and copies its dist/world3d/ into the project folder as world3d/; assets.js
40
+ // tells the page to load it from there. Its files load as modules, which a file:// page cannot, so a page with a Scene is opened
41
+ // from a server on this computer (127.0.0.1) — review, and stills / render / check headless.
42
+ const hasWorld = (project) => {
43
+ const walk = (channels, clips) => (channels || []).some((ch) => (ch?.items || []).some((it) => it?.rig?.renderer === 'world'))
44
+ || (clips || []).some((c) => c?.sequence && walk(c.sequence.channels, c.sequence.clips));
45
+ return !!project && walk(project.channels, project.clips);
46
+ };
47
+ const META = JSON.parse(readFileSync(path.join(PKG, 'package.json'), 'utf8'));
48
+ const VERSION = META.version;
49
+ // the plugin's releases this engine takes, and the world API its Scene layer speaks (schema/capabilities.json world3d, written by
50
+ // scripts/prepare.mjs from frontend/src/motionEngine/world3dContract.js)
51
+ const RANGE3D = META.peerDependencies?.[NAME3D] || null;
52
+ const API3D = () => SCHEMA().world3d?.api ?? 1;
53
+ let plugin = null;
54
+ function plugin3d({ quiet = false } = {}) {
55
+ if (plugin) return plugin;
56
+ try {
57
+ plugin = find3d({ range: RANGE3D, api: API3D(), from: [process.cwd(), PKG], log: quiet ? null : (m) => console.log(m) });
58
+ } catch (e) {
59
+ fail(`this video has a 3D Scene, and the 3D plugin (${NAME3D}${RANGE3D ? `@${RANGE3D}` : ''}) is not here: ${e.message}`,
60
+ 'run npx greenlight-motion add-3d while online, or set GREENLIGHT_MOTION_3D to an unpacked copy or its .tgz');
61
+ }
62
+ return plugin;
63
+ }
34
64
 
35
65
  // ── arguments ──
36
66
  const argv = process.argv.slice(2);
@@ -119,10 +149,11 @@ const indexHtml = (title) => `<!doctype html>
119
149
  // JSON inside a <script>: no "</script", no "<!--" (both escapes stay valid JSON and JS, so the app can read a page back)
120
150
  const scriptSafe = (s) => s.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\u0021--');
121
151
 
122
- const assetsJs = (assets, aliases) => `window.GL_ASSETS = ${scriptSafe(JSON.stringify(assets))};
152
+ // world: the folder holds the 3D plugin's files (world3d/): the page loads its 3D Scene's engine from there, not from the CDN
153
+ const assetsJs = (assets, aliases, world = false) => `window.GL_ASSETS = ${scriptSafe(JSON.stringify(assets))};
123
154
  window.GL_ASSET_ALIASES = ${scriptSafe(JSON.stringify(aliases))};
124
155
  (function (a, m) { for (var k in m) if (!(k in a)) a[k] = a[m[k]]; })(window.GL_ASSETS, window.GL_ASSET_ALIASES);
125
- `;
156
+ ${world ? 'window.GL_WORLD_BASE = "world3d/";\n' : ''}`;
126
157
 
127
158
  // The sounds a project's cues play: the library's files are on the sound CDN (schema/capabilities.json sounds.cdn;
128
159
  // GREENLIGHT_SOUND_CDN overrides it). A cue's file is downloaded into assets/sounds/ once. Returns the refs still missing.
@@ -183,6 +214,8 @@ function ensure3dFiles(dir, project) {
183
214
  else if (hdrs.has(`${s}.hdr`)) addHdr(`${s}.hdr`);
184
215
  }
185
216
  if ((project.clips || []).some((c) => c?.mediaType === 'model3d') && f.defaultHdr) addHdr(f.defaultHdr);
217
+ // a 3D Scene without an environment of its own is lit by the Scene's default one (rig3d.js ENV_DEFAULTS)
218
+ if (hasWorld(project)) addHdr('studio_small_03_1k.hdr');
186
219
  const missing = [];
187
220
  for (const [rel, [url, ref]] of want) {
188
221
  const dest = path.join(dir, 'assets', rel);
@@ -210,26 +243,60 @@ function build(dir, { quiet = false } = {}) {
210
243
  }
211
244
  const missing = [...[...refsIn(project)].filter((r) => !assets[r] && !aliases[r]), ...soundsMissing];
212
245
  writeFileSync(path.join(dir, 'project.js'), `window.GL_PROJECT = ${scriptSafe(JSON.stringify(project))};\n`);
213
- writeFileSync(path.join(dir, 'assets.js'), assetsJs(assets, aliases));
246
+ const world = hasWorld(project);
247
+ writeFileSync(path.join(dir, 'assets.js'), assetsJs(assets, aliases, world));
214
248
  copyFileSync(ENGINE, path.join(dir, 'greenlight-motion.js'));
249
+ if (world) {
250
+ // the 3D plugin's files beside the page (copied again only when its release changed)
251
+ const p = plugin3d({ quiet });
252
+ const to = path.join(dir, 'world3d');
253
+ let had = null;
254
+ try { had = JSON.parse(readFileSync(path.join(to, 'manifest.json'), 'utf8')).version; } catch { /* none yet */ }
255
+ if (had !== p.version || readdirSync(p.world).some((f) => !existsSync(path.join(to, f)))) {
256
+ rmSync(to, { recursive: true, force: true });
257
+ mkdirSync(to, { recursive: true });
258
+ for (const f of readdirSync(p.world)) copyFileSync(path.join(p.world, f), path.join(to, f));
259
+ }
260
+ }
215
261
  if (!existsSync(path.join(dir, 'index.html'))) writeFileSync(path.join(dir, 'index.html'), indexHtml(project.template?.title || project.name));
216
262
  if (!quiet) {
217
263
  console.log(`built ${path.join(dir, 'index.html')} — ${filesUnder(path.join(dir, 'assets')).length} file(s), ${(bytes / 1048576).toFixed(1)} MB of assets`);
264
+ if (world) console.log(`it has a 3D Scene (the 3D plugin ${plugin?.version} in ${path.join(dir, 'world3d')}): it plays from a local server, not by double-click — npx greenlight-motion review ${dir}`);
218
265
  if (missing.length) console.log(`missing (put them in assets/): ${missing.join(', ')}`);
219
266
  }
220
267
  return { project, assets, aliases, missing, bytes };
221
268
  }
222
269
 
223
- // The page, headless: built, opened from file://, settled (files are asked for in waves — a model, then its HDR).
270
+ // A project folder served read-only on 127.0.0.1 (a free port), for a page whose 3D Scene loads its engine as modules.
271
+ async function serveFolder(dir) {
272
+ const http = await import('node:http');
273
+ const root = path.resolve(dir);
274
+ const types = { ...MIME, '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8' };
275
+ const server = http.createServer((req, res) => {
276
+ const p = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
277
+ const file = path.resolve(root, p === '/' ? 'index.html' : p.replace(/^\/+/, ''));
278
+ if (!file.startsWith(root + path.sep) || !existsSync(file) || statSync(file).isDirectory() || (req.method !== 'GET' && req.method !== 'HEAD')) {
279
+ res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found'); return;
280
+ }
281
+ res.writeHead(200, { 'content-type': types[path.extname(file).toLowerCase()] || 'application/octet-stream', 'cache-control': 'no-store' });
282
+ res.end(req.method === 'HEAD' ? undefined : readFileSync(file));
283
+ });
284
+ await new Promise((ok, no) => { server.once('error', no); server.listen(0, '127.0.0.1', ok); });
285
+ return { url: `http://127.0.0.1:${server.address().port}/`, close: () => server.close() };
286
+ }
287
+
288
+ // The page, headless: built, opened from file:// (a 3D Scene's from a local server), settled (files are asked for in waves — a
289
+ // model, then its HDR).
224
290
  async function withPage(dir, fn) {
225
- build(dir, { quiet: true });
291
+ const { project } = build(dir, { quiet: true });
292
+ const served = hasWorld(project) ? await serveFolder(dir) : null;
226
293
  const browser = await launch();
227
294
  try {
228
295
  const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
229
296
  const page = await context.newPage();
230
297
  const errors = [];
231
298
  page.on('pageerror', (e) => errors.push(String(e.message || e)));
232
- await page.goto('file://' + path.resolve(dir, 'index.html'));
299
+ await page.goto(served ? served.url : 'file://' + path.resolve(dir, 'index.html'));
233
300
  await page.waitForFunction(() => window.GreenLightMotion?.page?.ready, null, { timeout: 90000 });
234
301
  const duration = await page.evaluate(() => window.GreenLightMotion.page.duration);
235
302
  for (const t of [0, duration / 2, Math.max(0, duration - 0.05)]) await page.evaluate((x) => window.GreenLightMotion.page.renderFrame(x), t);
@@ -237,6 +304,7 @@ async function withPage(dir, fn) {
237
304
  return await fn({ page, errors, duration });
238
305
  } finally {
239
306
  await browser.close();
307
+ served?.close();
240
308
  }
241
309
  }
242
310
  const parseTimes = (duration) => {
@@ -254,6 +322,13 @@ async function doctor() {
254
322
  const chrome = findChrome();
255
323
  add('Chrome or Edge', !!chrome, chrome || 'not found', 'install Google Chrome, or set GREENLIGHT_MOTION_CHROME to its executable');
256
324
  add('engine bundle', existsSync(ENGINE), existsSync(ENGINE) ? path.relative(process.cwd(), ENGINE) : 'missing', 'reinstall greenlight-motion');
325
+ // the 3D plugin: not a fault when it is not here yet (a video with a 3D Scene downloads it), only when what is here does not fit
326
+ let found3d = null, why3d = null;
327
+ try { found3d = find3d({ range: RANGE3D, api: API3D(), from: [process.cwd(), PKG], download: false }); } catch (e) { why3d = e.message; }
328
+ const others = cached().filter((c) => c.version !== found3d?.version).map((c) => c.version);
329
+ add(`3D plugin (${NAME3D}${RANGE3D ? ` ${RANGE3D}` : ''})`, !why3d,
330
+ found3d ? `${found3d.version} (${found3d.from === 'cache' ? 'cache' : found3d.from}: ${found3d.world})` : why3d || `not downloaded yet: a video with a 3D Scene gets it (once, ~19 MB into ${cacheRoot()}), or now: npx greenlight-motion add-3d${others.length ? `; other releases cached: ${others.join(', ')}` : ''}`,
331
+ 'unset GREENLIGHT_MOTION_3D or point it at a release that fits, then npx greenlight-motion add-3d');
257
332
  if (chrome) {
258
333
  try {
259
334
  const browser = await launch();
@@ -263,16 +338,18 @@ async function doctor() {
263
338
  const gpu = await page.evaluate(async () => {
264
339
  const a = await navigator.gpu?.requestAdapter();
265
340
  const h264 = typeof VideoEncoder === 'function' && (await VideoEncoder.isConfigSupported({ codec: 'avc1.640028', width: 1920, height: 1080, bitrate: 8e6, framerate: 30 })).supported;
266
- return { webgpu: !!a, adapter: a?.info ? [a.info.vendor, a.info.architecture].filter(Boolean).join(' ') : '', h264 };
341
+ return { webgpu: !!a, adapter: a?.info ? [a.info.vendor, a.info.architecture].filter(Boolean).join(' ') : '', h264,
342
+ storage: a?.limits?.maxStorageBuffersPerShaderStage || 0, maxBuffer: a?.limits?.maxBufferSize || 0 };
267
343
  });
268
344
  await browser.close();
269
345
  add('WebGPU (headless)', gpu.webgpu, gpu.adapter || (gpu.webgpu ? 'adapter' : 'no adapter'), 'update Chrome; on Linux servers WebGPU needs Vulkan or SwiftShader');
270
346
  add('H.264 encoding (WebCodecs)', gpu.h264, gpu.h264 ? 'supported' : 'not supported', 'use Google Chrome (open-source Chromium on Linux lacks H.264)');
347
+ if (gpu.webgpu) add('WebGPU limits for a 3D Scene', gpu.storage >= 8, `${gpu.storage} storage buffers per stage, largest buffer ${Math.round(gpu.maxBuffer / 1048576)} MB`, 'a Final 3D Scene needs 8+ storage buffers per shader stage: update Chrome or the graphics driver');
271
348
  } catch (e) {
272
349
  add('browser starts headless', false, String(e.message || e).split('\n')[0], 'check that Chrome opens on this computer');
273
350
  }
274
351
  }
275
- if (flags.json) console.log(JSON.stringify({ version: VERSION, ok: checks.every((c) => c.ok), checks }, null, 2));
352
+ if (flags.json) console.log(JSON.stringify({ version: VERSION, ok: checks.every((c) => c.ok), checks, plugin3d: found3d ? { version: found3d.version, from: found3d.from, dir: found3d.world } : null, range3d: RANGE3D }, null, 2));
276
353
  else {
277
354
  for (const c of checks) console.log(`${c.ok ? 'ok ' : 'FIX '} ${c.name.padEnd(28)} ${c.detail}${c.fix ? `\n → ${c.fix}` : ''}`);
278
355
  console.log(checks.every((c) => c.ok) ? '\nready' : '\nfix the lines marked FIX');
@@ -296,10 +373,28 @@ then: npx greenlight-motion check ${dir} → stills ${dir} → render ${dir}
296
373
 
297
374
  async function check() {
298
375
  const dir = rest[0] || '.';
299
- const { missing: unbuilt } = build(dir, { quiet: true });
376
+ const { missing: unbuilt, project: built } = build(dir, { quiet: true });
377
+ // a 3D Scene: the scene script's own strict check (the same as `script` and the chat connector), and its critique below
378
+ const S = hasWorld(built) ? await import(pathToFileURL(path.join(PKG, 'dist', 'greenlight-motion-script.mjs')).href) : null;
379
+ // a folder `script` made: its Scene is the script's `world`, so fields are named the script's way (world.render.mode)
380
+ const fromScript = existsSync(path.join(dir, 'script.json'));
381
+ const asScript = (t) => (!fromScript || !t ? t : String(t).replace(/(^|`)rig\.(?=[a-z])/g, '$1world.').replace(/(^|`)scene\./g, '$1world.scene.')
382
+ .replace(/(^|`)materials\./g, '$1world.scene.materials.'));
300
383
  const result = await withPage(dir, async ({ page, errors, duration }) => {
301
384
  const problems = [];
385
+ const notes = [];
302
386
  const project = JSON.parse(readFileSync(path.join(dir, 'project.json'), 'utf8'));
387
+ if (S?.validateWorld) {
388
+ for (const ch of project.channels || []) for (const it of ch?.items || []) {
389
+ if (it?.rig?.renderer !== 'world') continue;
390
+ const r = S.validateWorld(it.rig, { path: fromScript && it.id === 'world' ? 'world' : `${it.id}.rig` });
391
+ for (const e of r.errors) problems.push({ code: 'scene', ref: e.path, detail: e.message, fix: 'fix that field (skill/references/video-world3d.md, world3d-schema.json)' });
392
+ for (const w of r.warnings) notes.push({ code: 'scene', ref: w.path, detail: w.message, fix: 'the 3D engine does not use it as written' });
393
+ }
394
+ // what the shot shows, the lights, the picture's numbers: hints naming the field to change (the middle frame)
395
+ const crit = await page.evaluate(async (t) => (window.GreenLightMotion.page.critique ? window.GreenLightMotion.page.critique(t) : null), duration / 2).catch((e) => ({ hints: [{ kind: 'critique', text: `the 3D critique could not run: ${e.message}` }] }));
396
+ for (const h of crit?.hints || []) notes.push({ code: '3d-critique', ref: h.kind, detail: asScript(h.text), fix: h.fix ? `change ${asScript(h.fix)}` : 'see the hint' });
397
+ }
303
398
  for (const r of unbuilt) {
304
399
  problems.push(r.startsWith('asset:sounds/')
305
400
  ? { code: 'missing-file', ref: r, fix: `a cue's sound is downloaded from the GreenLight sound CDN and that failed: check the internet connection and run build again, or put the file at assets/${r.slice(6)}` }
@@ -345,7 +440,6 @@ async function check() {
345
440
  }
346
441
  for (const e of errors) problems.push({ code: 'page-error', detail: e, fix: 'a value in project.json the engine cannot use; compare with the references in the skill' });
347
442
  // notes: worth a look, not wrong by themselves — text the engine wraps onto more lines than it was written with
348
- const notes = [];
349
443
  for (const w of await page.evaluate((proj) => window.GreenLightMotion.textWraps?.(proj) || [], project)) {
350
444
  notes.push({ code: 'text-wraps', ref: w.id, rows: w.rows, written: w.written,
351
445
  detail: `"${w.text}" (${w.fontPx} px) wraps onto ${w.rows} lines in its ${w.widthPx} px box, written as ${w.written}`,
@@ -428,6 +522,10 @@ function pack() {
428
522
  if (missing.length) console.log(`missing (not inside): ${missing.join(', ')}`);
429
523
  return;
430
524
  }
525
+ if (hasWorld(project)) {
526
+ fail('a video with a 3D Scene does not fit in one HTML file (its 3D engine is the 3D plugin: ~19 MB of files it loads as modules)',
527
+ `render the MP4: npx greenlight-motion render ${dir} — or show the folder: npx greenlight-motion review ${dir} — or --glvideo for GreenLight Dash`);
528
+ }
431
529
  const out = path.resolve(String(flags.out || path.join(dir, `${(project.template?.title || project.name || 'video').replace(/[^\w-]+/g, '-')}.html`)));
432
530
  const engine = readFileSync(ENGINE, 'utf8');
433
531
  // Function replacers: a replacement STRING would expand $&, $' and $` — and minified code is full of them.
@@ -686,9 +784,16 @@ async function review() {
686
784
  await new Promise(() => {}); // serves until stopped
687
785
  }
688
786
 
689
- const COMMANDS = { doctor, init, script, build: () => build(rest[0] || '.'), check, stills, render, pack, set, apply, schema, trace, review };
787
+ // The 3D plugin now, before a video needs it (a scene built from zero, an offline day ahead)
788
+ function add3d() {
789
+ const p = plugin3d({ quiet: !!flags.json });
790
+ if (flags.json) console.log(JSON.stringify({ ok: true, package: NAME3D, version: p.version, from: p.from, dir: p.world, range: RANGE3D }));
791
+ else console.log(`3D plugin ${NAME3D} ${p.version} ready (${p.from}): ${p.world}`);
792
+ }
793
+
794
+ const COMMANDS = { doctor, init, script, build: () => build(rest[0] || '.'), check, stills, render, pack, set, apply, schema, trace, review, 'add-3d': add3d };
690
795
  if (!cmd || cmd === 'help' || flags.help || !COMMANDS[cmd]) {
691
- console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 20).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
796
+ console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 24).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
692
797
  process.exit(cmd && !COMMANDS[cmd] && cmd !== 'help' ? 1 : 0);
693
798
  }
694
799
  try {
@@ -0,0 +1,202 @@
1
+ // The 3D plugin (`greenlight-motion-3d`) for the CLI: found where it already is, or downloaded once into this computer's cache —
2
+ // the way Playwright keeps its browsers. A project folder with a 3D Scene gets the plugin's dist/world3d/ copied beside its page.
3
+ //
4
+ // Where it is looked for, in order:
5
+ // 1. GREENLIGHT_MOTION_3D: a folder (an unpacked package, or its dist/world3d/) or a .tgz of it — offline installs, tests
6
+ // 2. an installed package (`npm i greenlight-motion-3d` next to the project, or beside this one) whose version fits the range
7
+ // 3. the cache: <cache>/greenlight-motion-3d/<version>/ (GREENLIGHT_MOTION_3D_CACHE, else the OS's cache folder)
8
+ // 4. the npm registry (GREENLIGHT_MOTION_3D_REGISTRY, else npm_config_registry, else registry.npmjs.org): the newest release in
9
+ // the range, its tarball checked against the registry's sha512 before it is unpacked
10
+ // Synchronous (curl, like the CLI's sound and 3D file downloads), so `build` stays one plain step.
11
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import { createRequire } from 'node:module';
15
+ import { gunzipSync } from 'node:zlib';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ export const NAME = 'greenlight-motion-3d';
20
+ // the world API this engine's Scene layer speaks: frontend/src/motionEngine/world3dContract.js WORLD3D_API, which the CLI passes in from
21
+ // its scene script bundle (dist/greenlight-motion-script.mjs); this is only the fallback
22
+ export const API = 1;
23
+
24
+ /** Whether `version` is inside `range` (~x.y.z, ^x.y.z, x.y.z, or >= / < bounds separated by spaces). */
25
+ export function satisfies(version, range) {
26
+ const v = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version || '').trim());
27
+ if (!v) return false;
28
+ const n = v.slice(1).map(Number);
29
+ const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]);
30
+ return String(range || '').trim().split(/\s+/).every((part) => {
31
+ const m = /^(>=|<=|>|<|=|~|\^)?(\d+)\.(\d+)\.(\d+)$/.exec(part);
32
+ if (!m) return false;
33
+ const r = m.slice(2).map(Number);
34
+ switch (m[1]) {
35
+ case '>=': return cmp(n, r) >= 0;
36
+ case '<=': return cmp(n, r) <= 0;
37
+ case '>': return cmp(n, r) > 0;
38
+ case '<': return cmp(n, r) < 0;
39
+ case '~': return cmp(n, r) >= 0 && n[0] === r[0] && n[1] === r[1];
40
+ case '^': return cmp(n, r) >= 0 && (r[0] > 0 ? n[0] === r[0] : r[1] > 0 ? n[0] === 0 && n[1] === r[1] : n[0] === 0 && n[1] === 0 && n[2] === r[2]);
41
+ default: return cmp(n, r) === 0;
42
+ }
43
+ });
44
+ }
45
+ const newest = (list) => list.slice().sort((a, b) => {
46
+ const x = a.split('.').map(Number), y = b.split('.').map(Number);
47
+ return (y[0] - x[0]) || (y[1] - x[1]) || (y[2] - x[2]);
48
+ })[0];
49
+
50
+ /** The cache folder the plugin's releases are kept in. */
51
+ export function cacheRoot() {
52
+ if (process.env.GREENLIGHT_MOTION_3D_CACHE) return path.resolve(process.env.GREENLIGHT_MOTION_3D_CACHE);
53
+ const home = os.homedir();
54
+ if (process.platform === 'darwin') return path.join(home, 'Library', 'Caches', 'greenlight-motion', NAME);
55
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), 'greenlight-motion', 'Cache', NAME);
56
+ return path.join(process.env.XDG_CACHE_HOME || path.join(home, '.cache'), 'greenlight-motion', NAME);
57
+ }
58
+
59
+ /** A plugin folder's facts: { dir, world, version, api, manifest } or null when it is not one. `dir` may be the package or its dist/world3d. */
60
+ export function inspect(dir) {
61
+ const candidates = [path.join(dir, 'dist', 'world3d'), dir];
62
+ for (const world of candidates) {
63
+ const manifestFile = path.join(world, 'manifest.json');
64
+ if (!existsSync(path.join(world, 'world3d.js')) || !existsSync(manifestFile)) continue;
65
+ let manifest;
66
+ try { manifest = JSON.parse(readFileSync(manifestFile, 'utf8')); } catch { continue; }
67
+ return { dir: world === dir ? path.dirname(path.dirname(dir)) : dir, world, version: manifest.version, api: manifest.api, manifest };
68
+ }
69
+ return null;
70
+ }
71
+
72
+ /** Every file of the manifest there, with its size and sha256: the names of what is missing or changed ([] when the copy is whole). */
73
+ export function verify(found) {
74
+ const bad = [];
75
+ for (const [name, f] of Object.entries(found.manifest.files || {})) {
76
+ const p = path.join(found.world, name);
77
+ if (!existsSync(p)) { bad.push(`${name} (missing)`); continue; }
78
+ if (statSync(p).size !== f.bytes) { bad.push(`${name} (size)`); continue; }
79
+ if (f.sha256 && createHash('sha256').update(readFileSync(p)).digest('hex') !== f.sha256) bad.push(`${name} (content)`);
80
+ }
81
+ return bad;
82
+ }
83
+
84
+ // ── a .tgz → files (npm's tarballs: gzip'd ustar, every path under package/, pax headers for long names) ──
85
+ function untar(gz, into) {
86
+ const tar = gunzipSync(gz);
87
+ const str = (b, at, len) => { const s = b.subarray(at, at + len); const z = s.indexOf(0); return s.subarray(0, z < 0 ? len : z).toString('utf8'); };
88
+ let at = 0, longName = null, count = 0;
89
+ while (at + 512 <= tar.length) {
90
+ const head = tar.subarray(at, at + 512);
91
+ if (head.every((b) => b === 0)) break;
92
+ const size = parseInt(str(head, 124, 12).trim() || '0', 8) || 0;
93
+ const type = String.fromCharCode(head[156] || 48);
94
+ let name = longName || (str(head, 345, 155) ? `${str(head, 345, 155)}/${str(head, 0, 100)}` : str(head, 0, 100));
95
+ longName = null;
96
+ const body = tar.subarray(at + 512, at + 512 + size);
97
+ at += 512 + Math.ceil(size / 512) * 512;
98
+ if (type === 'x') { // pax: this entry's path for the next one
99
+ const m = /\d+ path=([^\n]*)\n/.exec(body.toString('utf8'));
100
+ if (m) longName = m[1];
101
+ continue;
102
+ }
103
+ if (type === 'g' || type === '5') continue;
104
+ if (type !== '0' && type !== '\0' && type !== '7') continue; // links and devices: never in a package of ours
105
+ name = name.replace(/^package\//, '');
106
+ if (!name || path.isAbsolute(name) || name.split(/[\\/]/).some((s) => s === '..')) throw new Error(`the 3D plugin's tarball has a path outside its folder: ${name}`);
107
+ const dest = path.join(into, name);
108
+ mkdirSync(path.dirname(dest), { recursive: true });
109
+ writeFileSync(dest, body);
110
+ count += 1;
111
+ }
112
+ return count;
113
+ }
114
+
115
+ const curl = (url, out = null) => {
116
+ const args = ['-sfL', '--max-time', '600', ...(out ? ['-o', out] : []), url];
117
+ const r = spawnSync('curl', args, { maxBuffer: 64 * 1024 * 1024 });
118
+ if (r.status !== 0) throw new Error(`could not download ${url} (curl exit ${r.status}${r.error ? `: ${r.error.message}` : ''})`);
119
+ return out ? null : r.stdout;
120
+ };
121
+
122
+ /** A .tgz unpacked into the cache under its own version; returns its folder (a version already there is kept as it is). */
123
+ function cacheTarball(bytes, { integrity = null, from = 'a tarball' } = {}) {
124
+ if (integrity) {
125
+ const [algo, want] = String(integrity).split('-');
126
+ const got = createHash(algo || 'sha512').update(bytes).digest('base64');
127
+ if (got !== want) throw new Error(`the 3D plugin downloaded from ${from} does not match the registry's ${algo} — not unpacked`);
128
+ }
129
+ const root = cacheRoot();
130
+ mkdirSync(root, { recursive: true });
131
+ const tmp = path.join(root, `.unpack-${process.pid}-${Date.now()}`);
132
+ try {
133
+ untar(bytes, tmp);
134
+ const found = inspect(tmp);
135
+ if (!found) throw new Error(`${from} is not the 3D plugin (no dist/world3d/manifest.json)`);
136
+ const meta = JSON.parse(readFileSync(path.join(tmp, 'package.json'), 'utf8'));
137
+ if (meta.name !== NAME) throw new Error(`${from} is ${meta.name}, not ${NAME}`);
138
+ const bad = verify(found);
139
+ if (bad.length) throw new Error(`${from} is not whole: ${bad.slice(0, 4).join(', ')}`);
140
+ const dest = path.join(root, meta.version);
141
+ if (existsSync(path.join(dest, '.complete'))) { rmSync(tmp, { recursive: true, force: true }); return dest; }
142
+ rmSync(dest, { recursive: true, force: true });
143
+ renameSync(tmp, dest);
144
+ writeFileSync(path.join(dest, '.complete'), `${new Date().toISOString()} ${from}\n`);
145
+ return dest;
146
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
147
+ }
148
+
149
+ /** The cached releases, newest first: [{ version, dir }]. */
150
+ export function cached() {
151
+ const root = cacheRoot();
152
+ if (!existsSync(root)) return [];
153
+ return readdirSync(root).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && existsSync(path.join(root, v, '.complete')))
154
+ .sort((a, b) => (newest([a, b]) === a ? -1 : 1)).map((version) => ({ version, dir: path.join(root, version) }));
155
+ }
156
+
157
+ /**
158
+ * The plugin for an engine that takes `range`: { dir, world, version, from: 'env' | 'installed' | 'cache' | 'download' }.
159
+ * `download: false` only looks (doctor). Throws a message for people when it cannot be had.
160
+ */
161
+ export function find3d({ range, api = API, download = true, from = [process.cwd()], log = null } = {}) {
162
+ const fits = (found) => found && found.api === api && (!range || satisfies(found.version, range));
163
+ const why = (found) => (found.api !== api ? `speaks world API ${found.api}, this engine ${api}` : `${found.version} is not in ${range}`);
164
+ const env = process.env.GREENLIGHT_MOTION_3D;
165
+ if (env) {
166
+ const p = path.resolve(env);
167
+ if (!existsSync(p)) throw new Error(`GREENLIGHT_MOTION_3D=${env}: no such file or folder`);
168
+ let found = statSync(p).isDirectory() ? inspect(p) : inspect(cacheTarball(readFileSync(p), { from: p }));
169
+ if (!found) throw new Error(`GREENLIGHT_MOTION_3D=${env} is not the 3D plugin (no dist/world3d/manifest.json)`);
170
+ if (!fits(found)) throw new Error(`GREENLIGHT_MOTION_3D=${env}: ${why(found)}`);
171
+ return { ...found, from: 'env' };
172
+ }
173
+ for (const base of from) {
174
+ try {
175
+ const req = createRequire(path.join(path.resolve(base), 'noop.js'));
176
+ const found = inspect(path.dirname(req.resolve(`${NAME}/package.json`)));
177
+ if (fits(found)) return { ...found, from: 'installed' };
178
+ } catch { /* not installed there */ }
179
+ }
180
+ for (const c of cached()) {
181
+ const found = inspect(c.dir);
182
+ if (fits(found)) return { ...found, from: 'cache' };
183
+ }
184
+ if (!download) return null;
185
+ const registry = String(process.env.GREENLIGHT_MOTION_3D_REGISTRY || process.env.npm_config_registry || 'https://registry.npmjs.org/').replace(/\/?$/, '/');
186
+ log?.(`downloading the 3D plugin (${NAME}${range ? `@${range}` : ''}) from ${registry} — once, into ${cacheRoot()}`);
187
+ let doc;
188
+ try { doc = JSON.parse(curl(`${registry}${NAME}`).toString('utf8')); } catch (e) { throw new Error(`the 3D plugin could not be looked up: ${e.message}. Online once (or set GREENLIGHT_MOTION_3D to a copy), then try again`); }
189
+ const versions = Object.keys(doc.versions || {}).filter((v) => /^\d+\.\d+\.\d+$/.test(v) && (!range || satisfies(v, range)));
190
+ if (!versions.length) throw new Error(`no release of ${NAME} fits this engine (${range}); the registry has ${Object.keys(doc.versions || {}).join(', ') || 'none'}`);
191
+ const version = newest(versions);
192
+ const meta = doc.versions[version];
193
+ const tmp = path.join(os.tmpdir(), `${NAME}-${version}-${process.pid}.tgz`);
194
+ try {
195
+ curl(meta.dist.tarball, tmp);
196
+ const dir = cacheTarball(readFileSync(tmp), { integrity: meta.dist.integrity || null, from: meta.dist.tarball });
197
+ const found = inspect(dir);
198
+ if (!fits(found)) throw new Error(`${NAME}@${version}: ${why(found)}`);
199
+ log?.(`3D plugin ${version} ready (${dir})`);
200
+ return { ...found, from: 'download' };
201
+ } finally { rmSync(tmp, { force: true }); }
202
+ }