greenlight-motion 0.0.0-stage → 0.1.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/CONTRIBUTING.md +17 -0
- package/LICENSE +177 -0
- package/NOTICE +23 -0
- package/README.md +98 -2
- package/THIRD_PARTY_LICENSES.md +1170 -0
- package/cli/apply.mjs +298 -0
- package/cli/glvideo.mjs +56 -0
- package/cli/index.mjs +601 -0
- package/dist/assets/bakeWorker-CvP5R8Rq.js +1 -0
- package/dist/assets/depthWorker-oN8vEaQm.js +1 -0
- package/dist/assets/imageVectorWorker-CGPQgtEd.js +1 -0
- package/dist/bundle-inputs.json +590 -0
- package/dist/greenlight-motion.js +22447 -0
- package/dist/greenlight-motion.mjs +111352 -0
- package/examples/starter/project.json +183 -0
- package/legal/PRO_CONTENT_TERMS.md +41 -0
- package/legal/README.md +24 -0
- package/legal/THIRD_PARTY_ASSETS.md +19 -0
- package/legal/TRADEMARKS.md +24 -0
- package/package.json +59 -4
- package/schema/capabilities.json +1 -0
- package/skill/SKILL.md +235 -0
- package/skill/references/plugins/effect-runtime-contract.md +220 -0
- package/skill/references/plugins/starter-effect-passes.js +99 -0
- package/skill/references/plugins/starter-effect-tsl.js +46 -0
- package/skill/references/plugins/starter-transition.js +89 -0
- package/skill/references/plugins/transition-runtime-contract.md +271 -0
- package/skill/references/video-3d.md +236 -0
- package/skill/references/video-backgrounds.md +147 -0
- package/skill/references/video-camera.md +208 -0
- package/skill/references/video-effects.md +1391 -0
- package/skill/references/video-grid.md +232 -0
- package/skill/references/video-keyframes.md +172 -0
- package/skill/references/video-particles.md +352 -0
- package/skill/references/video-paths.md +150 -0
- package/skill/references/video-projects.md +362 -0
- package/skill/references/video-shapes.md +147 -0
- package/skill/references/video-text-kinetic.md +447 -0
- package/skill/references/video-text-presets.md +1102 -0
- package/skill/references/video-text.md +342 -0
- package/skill/references/video-transitions.md +57 -0
package/cli/index.mjs
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// greenlight-motion — the GreenLight Motion Engine's command line, for AI agents (skill/SKILL.md).
|
|
3
|
+
//
|
|
4
|
+
// npx greenlight-motion doctor what this computer has, and how to fix what's missing
|
|
5
|
+
// npx greenlight-motion init <dir> [--from p.json] a project folder: index.html, project.json, assets/
|
|
6
|
+
// npx greenlight-motion build <dir> project.json + assets/ → a page the user double-clicks
|
|
7
|
+
// npx greenlight-motion check <dir> [--json] problems to fix (missing files, template, errors, blank frames)
|
|
8
|
+
// npx greenlight-motion stills <dir> [--at 0.5,2] [--count 6] [--out d] PNG frames + a labelled contact sheet
|
|
9
|
+
// npx greenlight-motion render <dir> [--fps 60] [--motion-blur] [--volume 80] [--no-sfx] [--out f.mp4]
|
|
10
|
+
// npx greenlight-motion pack <dir> [--out f.html] everything in ONE html file (share it, or put it in a Claude artifact)
|
|
11
|
+
// npx greenlight-motion pack <dir> --glvideo [--out f.glvideo] ONE .glvideo file: double-clicked, it opens in GreenLight Dash
|
|
12
|
+
// npx greenlight-motion set <dir> key=value … template values (what the page's options panel changes)
|
|
13
|
+
// npx greenlight-motion apply <dir> <edits.json|-> [--dry-run] [--json] an atomic, validated batch of edits (cli/apply.mjs)
|
|
14
|
+
// npx greenlight-motion trace <dir> [--fps 30] [--items a,b] [--out f.json] every keyframed layer's values per frame + its moments
|
|
15
|
+
// 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/
|
|
16
|
+
// npx greenlight-motion schema [topic] the project format (format | template | effects | transitions | easings | paths | expressions | sounds)
|
|
17
|
+
//
|
|
18
|
+
// Needs Node 18+ and Chrome or Edge (the engine draws with WebGPU; a headless *shell* has no WebGPU, so the CLI
|
|
19
|
+
// drives the real browser in its new headless mode). No ffmpeg: the page encodes the MP4 itself.
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, copyFileSync, rmSync } from 'node:fs';
|
|
22
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
|
+
import { applyEdits } from './apply.mjs';
|
|
26
|
+
import { glvideoBytes } from './glvideo.mjs';
|
|
27
|
+
|
|
28
|
+
const PKG = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
30
|
+
const ENGINE = path.join(PKG, 'dist', 'greenlight-motion.js');
|
|
31
|
+
const VERSION = JSON.parse(readFileSync(path.join(PKG, 'package.json'), 'utf8')).version;
|
|
32
|
+
|
|
33
|
+
// ── arguments ──
|
|
34
|
+
const argv = process.argv.slice(2);
|
|
35
|
+
const cmd = argv[0];
|
|
36
|
+
const flags = {};
|
|
37
|
+
const rest = [];
|
|
38
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
39
|
+
const a = argv[i];
|
|
40
|
+
if (a.startsWith('--')) {
|
|
41
|
+
const [k, v] = a.slice(2).split('=');
|
|
42
|
+
if (v !== undefined) flags[k] = v;
|
|
43
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) flags[k] = argv[++i];
|
|
44
|
+
else flags[k] = true;
|
|
45
|
+
} else rest.push(a);
|
|
46
|
+
}
|
|
47
|
+
const fail = (msg, hint) => {
|
|
48
|
+
console.error(`greenlight-motion: ${msg}${hint ? `\n → ${hint}` : ''}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// ── the browser ──
|
|
53
|
+
const CHROMES = {
|
|
54
|
+
darwin: ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium'],
|
|
55
|
+
win32: [`${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`, `${process.env['PROGRAMFILES(X86)']}\\Microsoft\\Edge\\Application\\msedge.exe`],
|
|
56
|
+
linux: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'],
|
|
57
|
+
};
|
|
58
|
+
function findChrome() {
|
|
59
|
+
if (process.env.GREENLIGHT_MOTION_CHROME && existsSync(process.env.GREENLIGHT_MOTION_CHROME)) return process.env.GREENLIGHT_MOTION_CHROME;
|
|
60
|
+
return (CHROMES[process.platform] || []).find((p) => p && existsSync(p)) || null;
|
|
61
|
+
}
|
|
62
|
+
async function launch() {
|
|
63
|
+
const chrome = findChrome();
|
|
64
|
+
if (!chrome) fail('no Chrome or Edge found', 'install Google Chrome, or set GREENLIGHT_MOTION_CHROME to its executable');
|
|
65
|
+
const { chromium } = require('playwright-core');
|
|
66
|
+
return chromium.launch({ executablePath: chrome, headless: true, args: ['--headless=new', '--enable-unsafe-webgpu'] });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── a project folder ──
|
|
70
|
+
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.avif': 'image/avif',
|
|
71
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.ogg': 'audio/ogg', '.aac': 'audio/aac',
|
|
72
|
+
'.glb': 'model/gltf-binary', '.gltf': 'model/gltf+json', '.hdr': 'application/octet-stream', '.exr': 'application/octet-stream', '.json': 'application/json',
|
|
73
|
+
'.ttf': 'font/ttf', '.otf': 'font/otf', '.woff': 'font/woff', '.woff2': 'font/woff2' };
|
|
74
|
+
const REF = /^(\/media\/|\/hdr\/|asset:|motion-engine-unbundled:)/;
|
|
75
|
+
|
|
76
|
+
function readProject(dir) {
|
|
77
|
+
const file = path.join(dir, 'project.json');
|
|
78
|
+
if (!existsSync(file)) fail(`no project.json in ${dir}`, `run: npx greenlight-motion init ${dir}`);
|
|
79
|
+
try { return JSON.parse(readFileSync(file, 'utf8')); } catch (e) { return fail(`project.json is not valid JSON: ${e.message}`); }
|
|
80
|
+
}
|
|
81
|
+
function filesUnder(root, base = root, out = []) {
|
|
82
|
+
if (!existsSync(root)) return out;
|
|
83
|
+
for (const name of readdirSync(root)) {
|
|
84
|
+
const p = path.join(root, name);
|
|
85
|
+
if (statSync(p).isDirectory()) filesUnder(p, base, out);
|
|
86
|
+
else out.push(path.relative(base, p).split(path.sep).join('/'));
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
// Every file under assets/ under the names a project or the engine may use for it.
|
|
91
|
+
function assetKeys(rel) {
|
|
92
|
+
const keys = [`asset:${rel}`];
|
|
93
|
+
if (rel.startsWith('media/')) keys.push(`/${rel}`);
|
|
94
|
+
if (rel.startsWith('hdr/')) keys.push(`/${rel}`);
|
|
95
|
+
if (rel.startsWith('models/')) keys.push(`motion-engine-unbundled:${rel.slice('models/'.length)}`);
|
|
96
|
+
return keys;
|
|
97
|
+
}
|
|
98
|
+
const refsIn = (v, out = new Set()) => {
|
|
99
|
+
if (typeof v === 'string') { if (REF.test(v)) out.add(v); }
|
|
100
|
+
else if (Array.isArray(v)) v.forEach((x) => refsIn(x, out));
|
|
101
|
+
else if (v && typeof v === 'object') Object.values(v).forEach((x) => refsIn(x, out));
|
|
102
|
+
return out;
|
|
103
|
+
};
|
|
104
|
+
const indexHtml = (title) => `<!doctype html>
|
|
105
|
+
<meta charset="utf-8">
|
|
106
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
107
|
+
<title>${String(title || 'GreenLight Motion').replace(/[<&]/g, '')}</title>
|
|
108
|
+
<!-- Made with the GreenLight Motion Engine. Open this file in Chrome or Edge. Rebuild after editing project.json:
|
|
109
|
+
npx greenlight-motion build . -->
|
|
110
|
+
<style>html, body { margin: 0; background: #0f1112; }</style>
|
|
111
|
+
<div id="app"></div>
|
|
112
|
+
<script src="greenlight-motion.js"></script>
|
|
113
|
+
<script src="project.js"></script>
|
|
114
|
+
<script src="assets.js"></script>
|
|
115
|
+
<script>GreenLightMotion.mountPage(document.getElementById('app'), { project: window.GL_PROJECT, assets: window.GL_ASSETS });</script>
|
|
116
|
+
`;
|
|
117
|
+
// JSON inside a <script>: no "</script", no "<!--" (both escapes stay valid JSON and JS, so the app can read a page back)
|
|
118
|
+
const scriptSafe = (s) => s.replace(/<\/(script)/gi, '<\\/$1').replace(/<!--/g, '<\\u0021--');
|
|
119
|
+
|
|
120
|
+
const assetsJs = (assets, aliases) => `window.GL_ASSETS = ${scriptSafe(JSON.stringify(assets))};
|
|
121
|
+
window.GL_ASSET_ALIASES = ${scriptSafe(JSON.stringify(aliases))};
|
|
122
|
+
(function (a, m) { for (var k in m) if (!(k in a)) a[k] = a[m[k]]; })(window.GL_ASSETS, window.GL_ASSET_ALIASES);
|
|
123
|
+
`;
|
|
124
|
+
|
|
125
|
+
// The sounds a project's cues play: the library's files are on the sound CDN (schema/capabilities.json sounds.cdn;
|
|
126
|
+
// GREENLIGHT_SOUND_CDN overrides it). A cue's file is downloaded into assets/sounds/ once. Returns the refs still missing.
|
|
127
|
+
const SCHEMA = () => {
|
|
128
|
+
try { return JSON.parse(readFileSync(path.join(PKG, 'schema', 'capabilities.json'), 'utf8')); } catch { return {}; }
|
|
129
|
+
};
|
|
130
|
+
const SOUND_SCHEMA = () => SCHEMA().sounds || {};
|
|
131
|
+
const SOUND_LIBRARY = () => SOUND_SCHEMA().library || {};
|
|
132
|
+
function ensureSoundFiles(dir, project) {
|
|
133
|
+
const lib = SOUND_LIBRARY();
|
|
134
|
+
const cdn = (process.env.GREENLIGHT_SOUND_CDN || SOUND_SCHEMA().cdn || '').replace(/\/+$/, '');
|
|
135
|
+
const missing = [];
|
|
136
|
+
for (const cue of Array.isArray(project.sounds) ? project.sounds : []) {
|
|
137
|
+
const s = lib[cue?.sfx];
|
|
138
|
+
if (!s) continue; // check reports an unknown sound
|
|
139
|
+
const dest = path.join(dir, 'assets', 'sounds', s.file);
|
|
140
|
+
if (existsSync(dest)) continue;
|
|
141
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
142
|
+
const r = spawnSync('curl', ['-sfL', '--max-time', '60', '-o', dest, `${cdn}/${encodeURIComponent(s.file)}`]);
|
|
143
|
+
if (r.status !== 0 || !existsSync(dest)) { rmSync(dest, { force: true }); missing.push(`asset:sounds/${s.file}`); }
|
|
144
|
+
}
|
|
145
|
+
// assets/sounds/ is build's own folder: a sound no cue uses any more (a cue removed, or a sound that left the
|
|
146
|
+
// library) goes, unless the project refers to the file itself
|
|
147
|
+
const folder = path.join(dir, 'assets', 'sounds');
|
|
148
|
+
if (existsSync(folder)) {
|
|
149
|
+
const used = new Set((Array.isArray(project.sounds) ? project.sounds : []).map((c) => lib[c?.sfx]?.file).filter(Boolean));
|
|
150
|
+
const named = refsIn(project);
|
|
151
|
+
for (const f of readdirSync(folder)) {
|
|
152
|
+
if (!used.has(f) && !named.has(`asset:sounds/${f}`)) rmSync(path.join(folder, f), { force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [...new Set(missing)];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The 3D files a project uses: device presets (`model-preset:<id>` → assets/models/<file>.glb, which the engine asks
|
|
159
|
+
// for as motion-engine-unbundled:<file>) and lighting environments (`/hdr/<name>.hdr` or a bare name such as
|
|
160
|
+
// "potsdamer_platz_1k" → assets/hdr/<name>.hdr; a model layer lights itself with the default one). They are on the
|
|
161
|
+
// CDN (schema files3d; GREENLIGHT_FILES_CDN overrides the host) and downloaded once. Returns the refs still missing.
|
|
162
|
+
const stringsIn = (v, out = new Set()) => {
|
|
163
|
+
if (typeof v === 'string') out.add(v);
|
|
164
|
+
else if (Array.isArray(v)) v.forEach((x) => stringsIn(x, out));
|
|
165
|
+
else if (v && typeof v === 'object') Object.values(v).forEach((x) => stringsIn(x, out));
|
|
166
|
+
return out;
|
|
167
|
+
};
|
|
168
|
+
function ensure3dFiles(dir, project) {
|
|
169
|
+
const f = SCHEMA().files3d;
|
|
170
|
+
if (!f) return [];
|
|
171
|
+
const host = (url) => (process.env.GREENLIGHT_FILES_CDN ? url.replace(/^https:\/\/[^/]+/, process.env.GREENLIGHT_FILES_CDN.replace(/\/+$/, '')) : url);
|
|
172
|
+
const want = new Map(); // assets/<rel> → [url, the ref the engine asks for]
|
|
173
|
+
const hdrs = new Set(f.hdrs || []);
|
|
174
|
+
const addHdr = (name) => { if (hdrs.has(name)) want.set(`hdr/${name}`, [`${host(f.hdr)}/${name}`, `/hdr/${name}`]); };
|
|
175
|
+
for (const s of stringsIn(project)) {
|
|
176
|
+
const preset = /^model-preset:([\w-]+)$/.exec(s)?.[1];
|
|
177
|
+
const file = preset && (f.presets?.[preset] || f.presets?.[f.retired?.[preset]]);
|
|
178
|
+
if (file) want.set(`models/${file}`, [`${host(f.models)}/${file}`, `motion-engine-unbundled:${file}`]);
|
|
179
|
+
const hdr = /^\/hdr\/([\w.-]+\.hdr)$/.exec(s)?.[1];
|
|
180
|
+
if (hdr) addHdr(hdr);
|
|
181
|
+
else if (hdrs.has(`${s}.hdr`)) addHdr(`${s}.hdr`);
|
|
182
|
+
}
|
|
183
|
+
if ((project.clips || []).some((c) => c?.mediaType === 'model3d') && f.defaultHdr) addHdr(f.defaultHdr);
|
|
184
|
+
const missing = [];
|
|
185
|
+
for (const [rel, [url, ref]] of want) {
|
|
186
|
+
const dest = path.join(dir, 'assets', rel);
|
|
187
|
+
if (existsSync(dest)) continue;
|
|
188
|
+
mkdirSync(path.dirname(dest), { recursive: true });
|
|
189
|
+
const r = spawnSync('curl', ['-sfL', '--max-time', '300', '-o', dest, url]);
|
|
190
|
+
if (r.status !== 0 || !existsSync(dest)) { rmSync(dest, { force: true }); missing.push(ref); }
|
|
191
|
+
}
|
|
192
|
+
return missing;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function build(dir, { quiet = false } = {}) {
|
|
196
|
+
if (!existsSync(ENGINE)) fail('the engine bundle is missing from this package', 'reinstall greenlight-motion');
|
|
197
|
+
const project = readProject(dir);
|
|
198
|
+
const soundsMissing = [...ensureSoundFiles(dir, project), ...ensure3dFiles(dir, project)];
|
|
199
|
+
const assets = {};
|
|
200
|
+
const aliases = {}; // other names of the same file: stored once, pointed at
|
|
201
|
+
let bytes = 0;
|
|
202
|
+
for (const rel of filesUnder(path.join(dir, 'assets'))) {
|
|
203
|
+
const buf = readFileSync(path.join(dir, 'assets', rel));
|
|
204
|
+
bytes += buf.length;
|
|
205
|
+
const [first, ...others] = assetKeys(rel);
|
|
206
|
+
assets[first] = `data:${MIME[path.extname(rel).toLowerCase()] || 'application/octet-stream'};base64,${buf.toString('base64')}`;
|
|
207
|
+
for (const k of others) aliases[k] = first;
|
|
208
|
+
}
|
|
209
|
+
const missing = [...[...refsIn(project)].filter((r) => !assets[r] && !aliases[r]), ...soundsMissing];
|
|
210
|
+
writeFileSync(path.join(dir, 'project.js'), `window.GL_PROJECT = ${scriptSafe(JSON.stringify(project))};\n`);
|
|
211
|
+
writeFileSync(path.join(dir, 'assets.js'), assetsJs(assets, aliases));
|
|
212
|
+
copyFileSync(ENGINE, path.join(dir, 'greenlight-motion.js'));
|
|
213
|
+
if (!existsSync(path.join(dir, 'index.html'))) writeFileSync(path.join(dir, 'index.html'), indexHtml(project.template?.title || project.name));
|
|
214
|
+
if (!quiet) {
|
|
215
|
+
console.log(`built ${path.join(dir, 'index.html')} — ${filesUnder(path.join(dir, 'assets')).length} file(s), ${(bytes / 1048576).toFixed(1)} MB of assets`);
|
|
216
|
+
if (missing.length) console.log(`missing (put them in assets/): ${missing.join(', ')}`);
|
|
217
|
+
}
|
|
218
|
+
return { project, assets, aliases, missing, bytes };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The page, headless: built, opened from file://, settled (files are asked for in waves — a model, then its HDR).
|
|
222
|
+
async function withPage(dir, fn) {
|
|
223
|
+
build(dir, { quiet: true });
|
|
224
|
+
const browser = await launch();
|
|
225
|
+
try {
|
|
226
|
+
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
|
|
227
|
+
const page = await context.newPage();
|
|
228
|
+
const errors = [];
|
|
229
|
+
page.on('pageerror', (e) => errors.push(String(e.message || e)));
|
|
230
|
+
await page.goto('file://' + path.resolve(dir, 'index.html'));
|
|
231
|
+
await page.waitForFunction(() => window.GreenLightMotion?.page?.ready, null, { timeout: 90000 });
|
|
232
|
+
const duration = await page.evaluate(() => window.GreenLightMotion.page.duration);
|
|
233
|
+
for (const t of [0, duration / 2, Math.max(0, duration - 0.05)]) await page.evaluate((x) => window.GreenLightMotion.page.renderFrame(x), t);
|
|
234
|
+
await page.waitForTimeout(600);
|
|
235
|
+
return await fn({ page, errors, duration });
|
|
236
|
+
} finally {
|
|
237
|
+
await browser.close();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const parseTimes = (duration) => {
|
|
241
|
+
if (flags.at) return String(flags.at).split(',').map(Number).filter((t) => Number.isFinite(t) && t >= 0);
|
|
242
|
+
const n = Math.max(1, Math.min(48, Number(flags.count) || 6));
|
|
243
|
+
return Array.from({ length: n }, (_, i) => +((duration * (i + 0.5)) / n).toFixed(3));
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// ── commands ──
|
|
247
|
+
async function doctor() {
|
|
248
|
+
const checks = [];
|
|
249
|
+
const add = (name, ok, detail, fix) => checks.push({ name, ok, detail, fix: ok ? undefined : fix });
|
|
250
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
251
|
+
add('Node.js 18 or newer', major >= 18, process.versions.node, 'install Node.js 18+ from nodejs.org');
|
|
252
|
+
const chrome = findChrome();
|
|
253
|
+
add('Chrome or Edge', !!chrome, chrome || 'not found', 'install Google Chrome, or set GREENLIGHT_MOTION_CHROME to its executable');
|
|
254
|
+
add('engine bundle', existsSync(ENGINE), existsSync(ENGINE) ? path.relative(process.cwd(), ENGINE) : 'missing', 'reinstall greenlight-motion');
|
|
255
|
+
if (chrome) {
|
|
256
|
+
try {
|
|
257
|
+
const browser = await launch();
|
|
258
|
+
const page = await browser.newPage();
|
|
259
|
+
// WebGPU and WebCodecs exist only in a secure context: a file:// page is one, about:blank is not.
|
|
260
|
+
await page.goto(pathToFileURL(path.join(PKG, 'package.json')).href);
|
|
261
|
+
const gpu = await page.evaluate(async () => {
|
|
262
|
+
const a = await navigator.gpu?.requestAdapter();
|
|
263
|
+
const h264 = typeof VideoEncoder === 'function' && (await VideoEncoder.isConfigSupported({ codec: 'avc1.640028', width: 1920, height: 1080, bitrate: 8e6, framerate: 30 })).supported;
|
|
264
|
+
return { webgpu: !!a, adapter: a?.info ? [a.info.vendor, a.info.architecture].filter(Boolean).join(' ') : '', h264 };
|
|
265
|
+
});
|
|
266
|
+
await browser.close();
|
|
267
|
+
add('WebGPU (headless)', gpu.webgpu, gpu.adapter || (gpu.webgpu ? 'adapter' : 'no adapter'), 'update Chrome; on Linux servers WebGPU needs Vulkan or SwiftShader');
|
|
268
|
+
add('H.264 encoding (WebCodecs)', gpu.h264, gpu.h264 ? 'supported' : 'not supported', 'use Google Chrome (open-source Chromium on Linux lacks H.264)');
|
|
269
|
+
} catch (e) {
|
|
270
|
+
add('browser starts headless', false, String(e.message || e).split('\n')[0], 'check that Chrome opens on this computer');
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (flags.json) console.log(JSON.stringify({ version: VERSION, ok: checks.every((c) => c.ok), checks }, null, 2));
|
|
274
|
+
else {
|
|
275
|
+
for (const c of checks) console.log(`${c.ok ? 'ok ' : 'FIX '} ${c.name.padEnd(28)} ${c.detail}${c.fix ? `\n → ${c.fix}` : ''}`);
|
|
276
|
+
console.log(checks.every((c) => c.ok) ? '\nready' : '\nfix the lines marked FIX');
|
|
277
|
+
}
|
|
278
|
+
process.exitCode = checks.every((c) => c.ok) ? 0 : 1;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function init() {
|
|
282
|
+
const dir = rest[0];
|
|
283
|
+
if (!dir) fail('init needs a folder', 'npx greenlight-motion init my-video');
|
|
284
|
+
if (existsSync(path.join(dir, 'project.json')) && !flags.force) fail(`${dir} already has a project.json`, 'pass --force to replace it');
|
|
285
|
+
mkdirSync(path.join(dir, 'assets'), { recursive: true });
|
|
286
|
+
const source = flags.from ? path.resolve(String(flags.from)) : path.join(PKG, 'examples', 'starter', 'project.json');
|
|
287
|
+
const project = JSON.parse(readFileSync(source, 'utf8'));
|
|
288
|
+
writeFileSync(path.join(dir, 'project.json'), JSON.stringify(project, null, 2) + '\n');
|
|
289
|
+
writeFileSync(path.join(dir, 'index.html'), indexHtml(project.template?.title || project.name));
|
|
290
|
+
build(dir);
|
|
291
|
+
console.log(`\nnext: edit ${path.join(dir, 'project.json')}, put pictures/video/sound in ${path.join(dir, 'assets')}/ (refer to them as "asset:<file>"),
|
|
292
|
+
then: npx greenlight-motion check ${dir} → stills ${dir} → render ${dir} (or open index.html)`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function check() {
|
|
296
|
+
const dir = rest[0] || '.';
|
|
297
|
+
const { missing: unbuilt } = build(dir, { quiet: true });
|
|
298
|
+
const result = await withPage(dir, async ({ page, errors, duration }) => {
|
|
299
|
+
const problems = [];
|
|
300
|
+
const project = JSON.parse(readFileSync(path.join(dir, 'project.json'), 'utf8'));
|
|
301
|
+
for (const r of unbuilt) {
|
|
302
|
+
problems.push(r.startsWith('asset:sounds/')
|
|
303
|
+
? { 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)}` }
|
|
304
|
+
: /^(motion-engine-unbundled:|\/hdr\/)/.test(r)
|
|
305
|
+
? { code: 'missing-file', ref: r, fix: `3D presets and lighting are downloaded from the GreenLight CDN and that failed: check the internet connection and run build again, or put the file in assets/${r.startsWith('/hdr/') ? r.slice(1) : `models/${r.split(':')[1]}`}` }
|
|
306
|
+
: { code: 'missing-file', ref: r, fix: `put the file in assets/ (${r.startsWith('asset:') ? r.slice(6) : r.replace(/^\//, '')}) and run build` });
|
|
307
|
+
}
|
|
308
|
+
for (const r of await page.evaluate(() => window.GreenLightMotion.missingAssets())) if (!unbuilt.includes(r)) problems.push({ code: 'missing-file', ref: r, fix: /^(motion-engine-unbundled:|\/hdr\/)/.test(r)
|
|
309
|
+
? `the engine asked for ${r}: build downloads 3D presets and lighting from the GreenLight CDN — check the internet connection and run build again, or put it in assets/ (models/ for .glb presets, hdr/ for .hdr)`
|
|
310
|
+
: `the engine asked for ${r}: put it in assets/ (models/ for .glb presets, hdr/ for .hdr)` });
|
|
311
|
+
for (const p of await page.evaluate((proj) => window.GreenLightMotion.checkTemplate(proj), project)) problems.push({ code: 'template', detail: p, fix: 'fix project.template.fields' });
|
|
312
|
+
for (const p of await page.evaluate(async (proj) => window.GreenLightMotion.checkSoundCues(await window.GreenLightMotion.normalize(proj)), project)) {
|
|
313
|
+
problems.push({ code: 'sound', detail: p, fix: 'fix project.sounds (npx greenlight-motion schema sounds lists the library)' });
|
|
314
|
+
}
|
|
315
|
+
if (!(duration > 0)) problems.push({ code: 'empty', detail: 'the project has no length', fix: 'add layers to channels[].items with clips that have a duration' });
|
|
316
|
+
const blank = [];
|
|
317
|
+
for (const t of parseTimes(duration).slice(0, 4)) {
|
|
318
|
+
const colours = await page.evaluate(async (time) => {
|
|
319
|
+
const src = await window.GreenLightMotion.page.renderFrame(time);
|
|
320
|
+
const img = await new Promise((ok) => { const i = new Image(); i.onload = () => ok(i); i.src = src; });
|
|
321
|
+
const c = document.createElement('canvas'); c.width = 96; c.height = 54;
|
|
322
|
+
const g = c.getContext('2d'); g.drawImage(img, 0, 0, 96, 54);
|
|
323
|
+
const d = g.getImageData(0, 0, 96, 54).data; const set = new Set();
|
|
324
|
+
for (let k = 0; k < d.length; k += 4) set.add((d[k] >> 4) * 256 + (d[k + 1] >> 4) * 16 + (d[k + 2] >> 4));
|
|
325
|
+
return set.size;
|
|
326
|
+
}, t);
|
|
327
|
+
if (colours <= 1) blank.push(t);
|
|
328
|
+
}
|
|
329
|
+
if (blank.length) problems.push({ code: 'blank-frames', times: blank, fix: 'nothing is on screen at these times: check layer timing (items, trimStart/trimEnd) and opacity' });
|
|
330
|
+
// plugins the page carries (assets/plugins/effects|transitions/): each one's own proof, as GreenLight Dash runs it
|
|
331
|
+
await page.waitForTimeout(600);
|
|
332
|
+
for (const p of await page.evaluate(() => window.GreenLightMotion.pluginStatus())) {
|
|
333
|
+
const ref = `plugins/${p.kind}/${p.id}`;
|
|
334
|
+
// a packaged one's own assets/ files are served by the page (frontend/src/motionEngine/assets.js)
|
|
335
|
+
if (p.ok === false) problems.push({ code: 'plugin', ref, detail: p.error, fix: 'fix the package (the message is its compile or draw error) and run check again' });
|
|
336
|
+
else if (p.ok == null) problems.push({ code: 'plugin', ref, detail: 'it did not load', fix: "check the package's exports against the custom-effects / advanced-transitions starter and that its file name is its id" });
|
|
337
|
+
}
|
|
338
|
+
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' });
|
|
339
|
+
return { ok: problems.length === 0, duration, problems };
|
|
340
|
+
});
|
|
341
|
+
if (flags.json) console.log(JSON.stringify(result, null, 2));
|
|
342
|
+
else {
|
|
343
|
+
console.log(result.ok ? `ok — ${result.duration.toFixed(2)} s, nothing to fix` : `${result.problems.length} problem(s):`);
|
|
344
|
+
for (const p of result.problems) console.log(`- ${p.code}: ${p.ref || p.detail || (p.times || []).join(', ')}\n → ${p.fix}`);
|
|
345
|
+
}
|
|
346
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function stills() {
|
|
350
|
+
const dir = rest[0] || '.';
|
|
351
|
+
const out = path.resolve(String(flags.out || path.join(dir, 'stills')));
|
|
352
|
+
mkdirSync(out, { recursive: true });
|
|
353
|
+
const written = await withPage(dir, async ({ page, duration }) => {
|
|
354
|
+
const times = parseTimes(duration);
|
|
355
|
+
const frames = [];
|
|
356
|
+
for (const t of times) {
|
|
357
|
+
const png = await page.evaluate((x) => window.GreenLightMotion.page.renderFrame(x), t);
|
|
358
|
+
const file = path.join(out, `frame_${t.toFixed(2).replace('.', '_')}s.png`);
|
|
359
|
+
writeFileSync(file, Buffer.from(png.split(',')[1], 'base64'));
|
|
360
|
+
frames.push({ t, file, png });
|
|
361
|
+
}
|
|
362
|
+
// A labelled contact sheet: one picture to look at, the time on every frame.
|
|
363
|
+
const sheet = await page.evaluate(async (list) => {
|
|
364
|
+
const imgs = await Promise.all(list.map((f) => new Promise((ok) => { const i = new Image(); i.onload = () => ok(i); i.src = f.png; })));
|
|
365
|
+
const cols = Math.min(4, imgs.length); const rows = Math.ceil(imgs.length / cols);
|
|
366
|
+
const w = 480; const h = Math.round((w * imgs[0].height) / imgs[0].width); const pad = 8; const label = 26;
|
|
367
|
+
const c = document.createElement('canvas'); c.width = cols * (w + pad) + pad; c.height = rows * (h + label + pad) + pad;
|
|
368
|
+
const g = c.getContext('2d'); g.fillStyle = '#16191b'; g.fillRect(0, 0, c.width, c.height);
|
|
369
|
+
imgs.forEach((img, i) => {
|
|
370
|
+
const x = pad + (i % cols) * (w + pad); const y = pad + Math.floor(i / cols) * (h + label + pad);
|
|
371
|
+
g.drawImage(img, x, y, w, h);
|
|
372
|
+
g.fillStyle = '#e8ecea'; g.font = '600 15px system-ui, sans-serif'; g.fillText(`${list[i].t.toFixed(2)} s`, x + 2, y + h + 19);
|
|
373
|
+
});
|
|
374
|
+
return c.toDataURL('image/png');
|
|
375
|
+
}, frames.map(({ t, png }) => ({ t, png })));
|
|
376
|
+
const sheetFile = path.join(out, 'contact-sheet.png');
|
|
377
|
+
writeFileSync(sheetFile, Buffer.from(sheet.split(',')[1], 'base64'));
|
|
378
|
+
return { frames: frames.map(({ t, file }) => ({ t, file })), sheet: sheetFile };
|
|
379
|
+
});
|
|
380
|
+
if (flags.json) console.log(JSON.stringify(written, null, 2));
|
|
381
|
+
else console.log(`${written.frames.length} frame(s) + contact sheet → ${written.sheet}\nLOOK at the contact sheet before you hand the video over.`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function render() {
|
|
385
|
+
const dir = rest[0] || '.';
|
|
386
|
+
const fps = Number(flags.fps) === 60 ? 60 : 30;
|
|
387
|
+
const project = readProject(dir);
|
|
388
|
+
const name = (project.template?.title || project.name || 'video').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'video';
|
|
389
|
+
const out = path.resolve(String(flags.out || path.join(dir, `${name}.mp4`)));
|
|
390
|
+
const t0 = Date.now();
|
|
391
|
+
const volume = flags.volume === undefined ? 100 : Number(flags.volume);
|
|
392
|
+
if (!Number.isFinite(volume) || volume < 0 || volume > 100) fail('--volume is a percentage, 0–100');
|
|
393
|
+
const mix = { volume, sfx: !flags['no-sfx'] };
|
|
394
|
+
const res = await withPage(dir, ({ page }) => page.evaluate(({ fps: f, mb, m }) => window.GreenLightMotion.page.renderVideo({ fps: f, motionBlur: mb, ...m }),
|
|
395
|
+
{ fps, mb: Boolean(flags['motion-blur']), m: mix }));
|
|
396
|
+
writeFileSync(out, Buffer.from(res.base64, 'base64'));
|
|
397
|
+
const sound = `${mix.volume !== 100 ? `, sound ${mix.volume}%` : ''}${mix.sfx ? '' : ', no sound effects'}`;
|
|
398
|
+
console.log(`rendered ${out} — ${fps} fps${res.motionBlur ? ', motion blur' : ''}${sound}, ${(res.size / 1048576).toFixed(2)} MB, ${((Date.now() - t0) / 1000).toFixed(1)} s`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function pack() {
|
|
402
|
+
const dir = rest[0] || '.';
|
|
403
|
+
const { project, assets, aliases, missing } = build(dir, { quiet: true });
|
|
404
|
+
if (flags.glvideo) {
|
|
405
|
+
// the project folder in one zip (cli/glvideo.mjs): project.json + every file under assets/
|
|
406
|
+
const title = project.template?.title || project.name;
|
|
407
|
+
const files = filesUnder(path.join(dir, 'assets')).map((rel) => ({ rel, data: readFileSync(path.join(dir, 'assets', ...rel.split('/'))) }));
|
|
408
|
+
const out = path.resolve(String(typeof flags.out === 'string' ? flags.out : path.join(dir, `${(title || 'video').replace(/[^\w-]+/g, '-')}.glvideo`)));
|
|
409
|
+
const bytes = glvideoBytes({ title, project, files });
|
|
410
|
+
writeFileSync(out, bytes);
|
|
411
|
+
console.log(`packed ${out} — ${(bytes.length / 1048576).toFixed(1)} MB, ${files.length} file(s). Double-clicked, it opens in GreenLight Dash as a new video project.`);
|
|
412
|
+
if (missing.length) console.log(`missing (not inside): ${missing.join(', ')}`);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const out = path.resolve(String(flags.out || path.join(dir, `${(project.template?.title || project.name || 'video').replace(/[^\w-]+/g, '-')}.html`)));
|
|
416
|
+
const engine = readFileSync(ENGINE, 'utf8');
|
|
417
|
+
// Function replacers: a replacement STRING would expand $&, $' and $` — and minified code is full of them.
|
|
418
|
+
const html = indexHtml(project.template?.title || project.name)
|
|
419
|
+
.replace('<script src="greenlight-motion.js"></script>', () => `<script>${scriptSafe(engine)}</script>`)
|
|
420
|
+
.replace('<script src="project.js"></script>', () => `<script>window.GL_PROJECT = ${scriptSafe(JSON.stringify(project))};</script>`)
|
|
421
|
+
.replace('<script src="assets.js"></script>', () => `<script>${assetsJs(assets, aliases)}</script>`);
|
|
422
|
+
writeFileSync(out, html);
|
|
423
|
+
const mb = Buffer.byteLength(html) / 1048576;
|
|
424
|
+
console.log(`packed ${out} — ${mb.toFixed(1)} MB${mb > 16 ? ' (over 16 MB: too big for a Claude artifact; move large media out or shorten it)' : ''}`);
|
|
425
|
+
if (missing.length) console.log(`missing (not inside): ${missing.join(', ')}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function set() {
|
|
429
|
+
const dir = rest[0] || '.';
|
|
430
|
+
const project = readProject(dir);
|
|
431
|
+
const fields = new Map((project.template?.fields || []).map((f) => [f.key, f]));
|
|
432
|
+
const values = { ...(project.template?.values || {}) };
|
|
433
|
+
for (const pair of rest.slice(1)) {
|
|
434
|
+
const eq = pair.indexOf('=');
|
|
435
|
+
if (eq < 1) fail(`"${pair}" is not key=value`);
|
|
436
|
+
const key = pair.slice(0, eq);
|
|
437
|
+
if (!fields.has(key)) fail(`the template has no field "${key}"`, `fields: ${[...fields.keys()].join(', ') || 'none'}`);
|
|
438
|
+
const raw = pair.slice(eq + 1);
|
|
439
|
+
values[key] = fields.get(key).type === 'number' ? Number(raw) : fields.get(key).type === 'toggle' ? raw === 'true' : raw;
|
|
440
|
+
}
|
|
441
|
+
project.template = { ...project.template, values };
|
|
442
|
+
writeFileSync(path.join(dir, 'project.json'), JSON.stringify(project, null, 2) + '\n');
|
|
443
|
+
build(dir, { quiet: true });
|
|
444
|
+
console.log(`set ${Object.keys(values).length} value(s); rebuilt ${path.join(dir, 'index.html')}`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// An atomic batch of edits (cli/apply.mjs): every edit checked, all or nothing, then the page is rebuilt.
|
|
448
|
+
async function apply() {
|
|
449
|
+
const dir = rest[0] || '.';
|
|
450
|
+
const src = rest[1];
|
|
451
|
+
if (!src) fail('which edits?', 'npx greenlight-motion apply <dir> edits.json (or - to read them from stdin)');
|
|
452
|
+
let raw;
|
|
453
|
+
if (src === '-') {
|
|
454
|
+
const chunks = [];
|
|
455
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
456
|
+
raw = Buffer.concat(chunks).toString('utf8');
|
|
457
|
+
} else {
|
|
458
|
+
if (!existsSync(src)) fail(`no file ${src}`);
|
|
459
|
+
raw = readFileSync(src, 'utf8');
|
|
460
|
+
}
|
|
461
|
+
let edits;
|
|
462
|
+
try { edits = JSON.parse(raw); } catch (e) { return fail(`the edits are not valid JSON: ${e.message}`); }
|
|
463
|
+
const project = readProject(dir);
|
|
464
|
+
let res;
|
|
465
|
+
try {
|
|
466
|
+
res = applyEdits(project, edits);
|
|
467
|
+
} catch (e) {
|
|
468
|
+
if (flags.json) {
|
|
469
|
+
console.log(JSON.stringify({ ok: false, edit: e.index >= 0 ? e.index + 1 : null, error: e.message, hint: e.hint, problems: e.problems || [] }));
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
for (const p of (e.problems || []).slice(1)) console.error(` also: ${p.message}${p.hint ? ` → ${p.hint}` : ''}`);
|
|
473
|
+
return fail(`${e.message} — nothing was changed`, e.hint);
|
|
474
|
+
}
|
|
475
|
+
if (!flags['dry-run']) {
|
|
476
|
+
writeFileSync(path.join(dir, 'project.json'), JSON.stringify(res.project, null, 2) + '\n');
|
|
477
|
+
build(dir, { quiet: true });
|
|
478
|
+
}
|
|
479
|
+
if (flags.json) console.log(JSON.stringify({ ok: true, applied: res.summary.length, summary: res.summary, written: !flags['dry-run'] }));
|
|
480
|
+
else {
|
|
481
|
+
console.log(`${flags['dry-run'] ? 'would apply' : 'applied'} ${res.summary.length} edit(s)${flags['dry-run'] ? ' (dry run — nothing written)' : `; rebuilt ${path.join(dir, 'index.html')}`}`);
|
|
482
|
+
for (const line of res.summary) console.log(`- ${line}`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function schema() {
|
|
487
|
+
const topic = rest[0] || 'format';
|
|
488
|
+
const caps = () => JSON.parse(readFileSync(path.join(PKG, 'schema', 'capabilities.json'), 'utf8'));
|
|
489
|
+
const show = (v) => console.log(typeof v === 'string' ? v : JSON.stringify(v, null, 2));
|
|
490
|
+
switch (topic) {
|
|
491
|
+
case 'format': {
|
|
492
|
+
const refs = path.join(PKG, 'skill', 'references');
|
|
493
|
+
show(`The project format is the GreenLight Dash Video Editor's. Read:
|
|
494
|
+
${path.join(PKG, 'skill', 'SKILL.md')} how to work, and the essentials
|
|
495
|
+
${refs}/ one file per topic: ${existsSync(refs) ? readdirSync(refs).map((f) => f.replace('.md', '')).join(', ') : '(missing)'}
|
|
496
|
+
Machine-readable: npx greenlight-motion schema effects | transitions | easings | paths | expressions | sounds | template`);
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
case 'template': show(readFileSync(path.join(PKG, 'skill', 'SKILL.md'), 'utf8').split('## Template')[1]?.split('\n## ')[0] || 'see SKILL.md'); break;
|
|
500
|
+
case 'effects': show(caps().effects); break;
|
|
501
|
+
case 'transitions': show(caps().transitions); break;
|
|
502
|
+
case 'easings': show(caps().easings); break;
|
|
503
|
+
case 'paths': show(caps().paths); break;
|
|
504
|
+
case 'expressions': show(caps().keyframeExpressions); break;
|
|
505
|
+
case 'sounds': show(caps().sounds); break;
|
|
506
|
+
default: fail(`unknown topic "${topic}"`, 'format | template | effects | transitions | easings | paths | expressions | sounds');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Motion data: every keyframed layer's animated values per frame + its moments (start, end, peak, settles).
|
|
511
|
+
async function trace() {
|
|
512
|
+
const dir = rest[0] || '.';
|
|
513
|
+
build(dir, { quiet: true });
|
|
514
|
+
const fps = Number(flags.fps) || 30;
|
|
515
|
+
const only = flags.items ? String(flags.items).split(',') : null;
|
|
516
|
+
const data = await withPage(dir, ({ page }) => page.evaluate(async ([f, items]) => {
|
|
517
|
+
const project = window.GL_PROJECT;
|
|
518
|
+
return window.GreenLightMotion.trace(project, { fps: f, items });
|
|
519
|
+
}, [fps, only]));
|
|
520
|
+
const out = flags.out ? path.resolve(String(flags.out)) : path.join(dir, 'trace.json');
|
|
521
|
+
writeFileSync(out, JSON.stringify(data, null, 1));
|
|
522
|
+
if (flags.json) console.log(JSON.stringify(data));
|
|
523
|
+
else {
|
|
524
|
+
console.log(`traced ${data.layers.length} animated layer(s) at ${fps} fps → ${out}`);
|
|
525
|
+
for (const l of data.layers) {
|
|
526
|
+
console.log(`- ${l.id} (${l.type}) ${l.start}–${l.end} s · tracks ${Object.keys(l.tracks).join(', ')} · peak ${l.moments.peak ?? '—'} s`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// The page served on this computer for the user's review (`localhost`, nothing leaves it). The notes they mark on the
|
|
532
|
+
// frame — each a pin, box, arrow or drawing with their words, the moment and the layers under it — are written to
|
|
533
|
+
// <dir>/review/: notes.json, NOTES.md (what the agent reads) and a picture of each moment with the marks numbered.
|
|
534
|
+
async function review() {
|
|
535
|
+
const dir = path.resolve(rest[0] || '.');
|
|
536
|
+
build(dir, { quiet: true });
|
|
537
|
+
const http = await import('node:http');
|
|
538
|
+
const out = path.join(dir, 'review');
|
|
539
|
+
const types = { ...MIME, '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.md': 'text/markdown; charset=utf-8' };
|
|
540
|
+
const send = (res, code, body, type = 'application/json') => {
|
|
541
|
+
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
|
|
542
|
+
res.end(body);
|
|
543
|
+
};
|
|
544
|
+
const body = (req) => new Promise((ok, no) => {
|
|
545
|
+
const chunks = [];
|
|
546
|
+
req.on('data', (c) => chunks.push(c));
|
|
547
|
+
req.on('end', () => ok(Buffer.concat(chunks)));
|
|
548
|
+
req.on('error', no);
|
|
549
|
+
});
|
|
550
|
+
const server = http.createServer(async (req, res) => {
|
|
551
|
+
try {
|
|
552
|
+
const p = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
|
|
553
|
+
if (p === '/__glm/review') {
|
|
554
|
+
if (req.method === 'GET') {
|
|
555
|
+
const f = path.join(out, 'notes.json');
|
|
556
|
+
return send(res, 200, existsSync(f) ? readFileSync(f) : JSON.stringify({ notes: [] }));
|
|
557
|
+
}
|
|
558
|
+
if (req.method === 'PUT') {
|
|
559
|
+
const j = JSON.parse((await body(req)).toString('utf8'));
|
|
560
|
+
mkdirSync(out, { recursive: true });
|
|
561
|
+
writeFileSync(path.join(out, 'notes.json'), JSON.stringify({ notes: Array.isArray(j.notes) ? j.notes : [] }, null, 1));
|
|
562
|
+
writeFileSync(path.join(out, 'NOTES.md'), String(j.markdown || ''));
|
|
563
|
+
console.log(`${new Date().toLocaleTimeString()} ${(j.notes || []).length} note(s) → ${path.join(out, 'NOTES.md')}`);
|
|
564
|
+
return send(res, 200, '{"ok":true}');
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
const img = /^\/__glm\/review\/image\/(at-[\d.]+s\.png)$/.exec(p);
|
|
568
|
+
if (img) {
|
|
569
|
+
const f = path.join(out, img[1]);
|
|
570
|
+
if (req.method === 'PUT') { mkdirSync(out, { recursive: true }); writeFileSync(f, await body(req)); return send(res, 200, '{"ok":true}'); }
|
|
571
|
+
if (req.method === 'DELETE') { rmSync(f, { force: true }); return send(res, 200, '{"ok":true}'); }
|
|
572
|
+
}
|
|
573
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'not allowed', 'text/plain');
|
|
574
|
+
const file = path.resolve(dir, p === '/' ? 'index.html' : p.replace(/^\/+/, ''));
|
|
575
|
+
if (!file.startsWith(dir + path.sep) || !existsSync(file) || statSync(file).isDirectory()) return send(res, 404, 'not found', 'text/plain');
|
|
576
|
+
return send(res, 200, readFileSync(file), types[path.extname(file).toLowerCase()] || 'application/octet-stream');
|
|
577
|
+
} catch (e) {
|
|
578
|
+
return send(res, 500, JSON.stringify({ error: String(e?.message || e) }));
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
const port = flags.port !== undefined ? Number(flags.port) : 4810;
|
|
582
|
+
await new Promise((ok, no) => { server.once('error', no); server.listen(port, '127.0.0.1', ok); });
|
|
583
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
584
|
+
console.log(`review: ${url}\n Open it in Chrome or Edge. Notes marked on the page land in ${out}/ (NOTES.md, notes.json, a picture of each moment).\n Ctrl+C to stop.`);
|
|
585
|
+
if (flags.open) {
|
|
586
|
+
const opener = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
587
|
+
spawn(opener[0], opener[1], { stdio: 'ignore', detached: true }).unref();
|
|
588
|
+
}
|
|
589
|
+
await new Promise(() => {}); // serves until stopped
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const COMMANDS = { doctor, init, build: () => build(rest[0] || '.'), check, stills, render, pack, set, apply, schema, trace, review };
|
|
593
|
+
if (!cmd || cmd === 'help' || flags.help || !COMMANDS[cmd]) {
|
|
594
|
+
console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 19).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
|
|
595
|
+
process.exit(cmd && !COMMANDS[cmd] && cmd !== 'help' ? 1 : 0);
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
await COMMANDS[cmd]();
|
|
599
|
+
} catch (e) {
|
|
600
|
+
fail(e?.message || String(e));
|
|
601
|
+
}
|