greenlight-motion 0.4.0 → 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 +4 -0
- package/README.md +37 -9
- package/THIRD_PARTY_LICENSES.md +1 -1
- package/cli/index.mjs +118 -13
- package/cli/plugin3d.mjs +202 -0
- package/dist/assets/rigWorker-CGpWyn_f.js +1 -0
- package/dist/bundle-inputs.json +36 -0
- package/dist/greenlight-motion-script.mjs +16524 -202
- package/dist/greenlight-motion.js +698 -688
- package/dist/greenlight-motion.mjs +78778 -74492
- package/dist/script-inputs.json +6 -1
- package/package.json +9 -1
- package/schema/capabilities.json +1 -1
- package/skill/SKILL.md +22 -11
- package/skill/references/scene-script.md +86 -0
- package/skill/references/video-3d.md +223 -8
- package/skill/references/video-camera.md +3 -2
- package/skill/references/video-effects.md +13 -1
- package/skill/references/video-keyframes.md +6 -0
- package/skill/references/video-particles.md +5 -11
- package/skill/references/video-paths.md +2 -1
- package/skill/references/video-projects.md +18 -11
- package/skill/references/video-shapes.md +1 -0
- package/skill/references/video-text.md +43 -7
- package/skill/references/video-world3d.md +1548 -0
- package/skill/references/world3d-schema.json +15118 -0
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.
|
|
21
|
-
npx greenlight-motion@0.
|
|
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.
|
|
25
|
-
npx greenlight-motion@0.
|
|
26
|
-
npx greenlight-motion@0.
|
|
27
|
-
npx greenlight-motion@0.
|
|
28
|
-
npx greenlight-motion@0.
|
|
29
|
-
npx greenlight-motion@0.
|
|
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.
|
|
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 */ },
|
|
@@ -108,6 +108,34 @@ const { blob } = await engine.export({ format: 'mp4', fps: 60 });
|
|
|
108
108
|
In a Claude artifact a page can't start a download by itself, so the page saves the MP4 through the
|
|
109
109
|
artifact's `downloads` capability.
|
|
110
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
|
+
|
|
111
139
|
## Requirements
|
|
112
140
|
|
|
113
141
|
- **Viewing and rendering:** desktop Chrome or Edge with WebGPU (Chrome 113+).
|
package/THIRD_PARTY_LICENSES.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Third-party licences
|
|
2
2
|
|
|
3
|
-
The engine bundle (`dist/greenlight-motion.js`, `dist/greenlight-motion.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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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 {
|
package/cli/plugin3d.mjs
ADDED
|
@@ -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
|
+
}
|