stereoframe 0.2.2 → 0.2.4
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/dist/cli.js +170 -32
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import { readFileSync, existsSync as
|
|
5
|
-
import { join as
|
|
4
|
+
import { readFileSync as readFileSync2, existsSync as existsSync5 } from "node:fs";
|
|
5
|
+
import { join as join5, resolve as resolve7 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// src/blocks.ts
|
|
8
8
|
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
@@ -58,6 +58,122 @@ ${listBlocks()}`);
|
|
|
58
58
|
`);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// src/gen.ts
|
|
62
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "node:fs";
|
|
63
|
+
import { join as join2, resolve as resolve2 } from "node:path";
|
|
64
|
+
var MESHY_BASE = "https://api.meshy.ai";
|
|
65
|
+
var TEST_KEY = "msy_dummy_api_key_for_test_mode_12345678";
|
|
66
|
+
function resolveKey(projectDir, explicit) {
|
|
67
|
+
if (explicit)
|
|
68
|
+
return { key: explicit, isTest: false };
|
|
69
|
+
if (process.env.MESHY_API_KEY)
|
|
70
|
+
return { key: process.env.MESHY_API_KEY, isTest: false };
|
|
71
|
+
const envPath = join2(resolve2(projectDir), ".env");
|
|
72
|
+
if (existsSync2(envPath)) {
|
|
73
|
+
const line = readFileSync(envPath, "utf8").split(`
|
|
74
|
+
`).find((l) => l.startsWith("MESHY_API_KEY="));
|
|
75
|
+
const v = line?.slice("MESHY_API_KEY=".length).trim();
|
|
76
|
+
if (v)
|
|
77
|
+
return { key: v, isTest: false };
|
|
78
|
+
}
|
|
79
|
+
return { key: TEST_KEY, isTest: true };
|
|
80
|
+
}
|
|
81
|
+
function slug(prompt) {
|
|
82
|
+
return prompt.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "model";
|
|
83
|
+
}
|
|
84
|
+
async function meshy(path, key, init) {
|
|
85
|
+
const res = await fetch(MESHY_BASE + path, {
|
|
86
|
+
...init,
|
|
87
|
+
headers: {
|
|
88
|
+
Authorization: `Bearer ${key}`,
|
|
89
|
+
"Content-Type": "application/json",
|
|
90
|
+
...init?.headers ?? {}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
const text = await res.text();
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
throw new Error(`Meshy ${init?.method ?? "GET"} ${path} → ${res.status}: ${text.slice(0, 300)}`);
|
|
96
|
+
}
|
|
97
|
+
return text ? JSON.parse(text) : {};
|
|
98
|
+
}
|
|
99
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
100
|
+
async function pollTask(taskId, key, label) {
|
|
101
|
+
const deadline = Date.now() + 8 * 60000;
|
|
102
|
+
let lastProgress = -1;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const task = await meshy(`/openapi/v2/text-to-3d/${taskId}`, key);
|
|
105
|
+
const status = task.status;
|
|
106
|
+
const progress = Number(task.progress ?? 0);
|
|
107
|
+
if (progress !== lastProgress) {
|
|
108
|
+
process.stdout.write(`\r ${label}: ${status} ${progress}% `);
|
|
109
|
+
lastProgress = progress;
|
|
110
|
+
}
|
|
111
|
+
if (status === "SUCCEEDED") {
|
|
112
|
+
process.stdout.write(`
|
|
113
|
+
`);
|
|
114
|
+
return task;
|
|
115
|
+
}
|
|
116
|
+
if (status === "FAILED" || status === "CANCELED") {
|
|
117
|
+
process.stdout.write(`
|
|
118
|
+
`);
|
|
119
|
+
throw new Error(`${label} ${status}: ${task.task_error?.message ?? "unknown error"}`);
|
|
120
|
+
}
|
|
121
|
+
await sleep(3000);
|
|
122
|
+
}
|
|
123
|
+
throw new Error(`${label} timed out after 8 minutes`);
|
|
124
|
+
}
|
|
125
|
+
async function genModel(opts) {
|
|
126
|
+
const projectDir = resolve2(opts.projectDir);
|
|
127
|
+
const { key, isTest } = resolveKey(projectDir, opts.key);
|
|
128
|
+
if (isTest) {
|
|
129
|
+
console.log(`⚠ no MESHY_API_KEY found — using Meshy test mode (returns a SAMPLE model, ignores the prompt).
|
|
130
|
+
` + " Set MESHY_API_KEY in your shell or project .env for real generations (https://www.meshy.ai/settings/api).");
|
|
131
|
+
}
|
|
132
|
+
const out = resolve2(projectDir, opts.out ?? join2("assets", `${slug(opts.prompt)}.glb`));
|
|
133
|
+
mkdirSync2(join2(out, ".."), { recursive: true });
|
|
134
|
+
console.log(`generating "${opts.prompt}" → ${out}`);
|
|
135
|
+
const preview = await meshy("/openapi/v2/text-to-3d", key, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
mode: "preview",
|
|
139
|
+
prompt: opts.prompt,
|
|
140
|
+
ai_model: "latest",
|
|
141
|
+
target_formats: ["glb"],
|
|
142
|
+
should_remesh: true,
|
|
143
|
+
...opts.polycount ? { target_polycount: opts.polycount } : {}
|
|
144
|
+
})
|
|
145
|
+
});
|
|
146
|
+
const previewId = preview.result;
|
|
147
|
+
const previewTask = await pollTask(previewId, key, "preview");
|
|
148
|
+
let finalTask = previewTask;
|
|
149
|
+
if (opts.texture) {
|
|
150
|
+
const refine = await meshy("/openapi/v2/text-to-3d", key, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
mode: "refine",
|
|
154
|
+
preview_task_id: previewId,
|
|
155
|
+
enable_pbr: true,
|
|
156
|
+
target_formats: ["glb"]
|
|
157
|
+
})
|
|
158
|
+
});
|
|
159
|
+
finalTask = await pollTask(refine.result, key, "texture");
|
|
160
|
+
}
|
|
161
|
+
const glbUrl = finalTask.model_urls?.glb;
|
|
162
|
+
if (!glbUrl)
|
|
163
|
+
throw new Error("Meshy returned no GLB url");
|
|
164
|
+
const glb = await fetch(glbUrl);
|
|
165
|
+
if (!glb.ok)
|
|
166
|
+
throw new Error(`downloading GLB → ${glb.status}`);
|
|
167
|
+
writeFileSync(out, Buffer.from(await glb.arrayBuffer()));
|
|
168
|
+
const rel = out.startsWith(projectDir) ? out.slice(projectDir.length + 1) : out;
|
|
169
|
+
console.log(`
|
|
170
|
+
✓ saved ${rel}`);
|
|
171
|
+
console.log(` use it: <sf-model src="${rel}" scale="1"></sf-model>`);
|
|
172
|
+
if (isTest)
|
|
173
|
+
console.log(" (sample model — set MESHY_API_KEY to generate from your prompt)");
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
61
177
|
// src/lint.ts
|
|
62
178
|
import { ASSET_ATTRS, EASE_NAMES, ELEMENT_NAMES, VERB_NAMES } from "stereoframe-runtime/vocab";
|
|
63
179
|
function readAttr(attrs, name) {
|
|
@@ -125,6 +241,8 @@ function lintHtml(html, opts) {
|
|
|
125
241
|
const value = readAttr(tag.attrs, attr);
|
|
126
242
|
if (!value)
|
|
127
243
|
continue;
|
|
244
|
+
if (attr === "environment" && (value === "room" || value === "studio"))
|
|
245
|
+
continue;
|
|
128
246
|
if (/^https?:\/\//i.test(value)) {
|
|
129
247
|
findings.push({
|
|
130
248
|
rule: "remote_asset",
|
|
@@ -253,16 +371,16 @@ function lintHtml(html, opts) {
|
|
|
253
371
|
|
|
254
372
|
// src/render.ts
|
|
255
373
|
import { spawn } from "node:child_process";
|
|
256
|
-
import { mkdirSync as
|
|
257
|
-
import { dirname, resolve as
|
|
374
|
+
import { mkdirSync as mkdirSync3 } from "node:fs";
|
|
375
|
+
import { dirname, resolve as resolve4 } from "node:path";
|
|
258
376
|
|
|
259
377
|
// src/session.ts
|
|
260
378
|
import puppeteer from "puppeteer";
|
|
261
379
|
|
|
262
380
|
// src/serve.ts
|
|
263
381
|
import { createServer } from "node:http";
|
|
264
|
-
import { createReadStream, existsSync as
|
|
265
|
-
import { extname, join as
|
|
382
|
+
import { createReadStream, existsSync as existsSync3, statSync } from "node:fs";
|
|
383
|
+
import { extname, join as join3, normalize, resolve as resolve3 } from "node:path";
|
|
266
384
|
var MIME = {
|
|
267
385
|
".html": "text/html; charset=utf-8",
|
|
268
386
|
".js": "text/javascript; charset=utf-8",
|
|
@@ -290,7 +408,7 @@ var MIME = {
|
|
|
290
408
|
".otf": "font/otf"
|
|
291
409
|
};
|
|
292
410
|
function serveProject(projectDir, fixedPort = 0) {
|
|
293
|
-
const root =
|
|
411
|
+
const root = resolve3(projectDir);
|
|
294
412
|
const server = createServer((req, res) => {
|
|
295
413
|
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
|
|
296
414
|
if (urlPath === "/favicon.ico") {
|
|
@@ -298,15 +416,15 @@ function serveProject(projectDir, fixedPort = 0) {
|
|
|
298
416
|
return;
|
|
299
417
|
}
|
|
300
418
|
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
|
301
|
-
let filePath =
|
|
419
|
+
let filePath = join3(root, rel);
|
|
302
420
|
if (!filePath.startsWith(root)) {
|
|
303
421
|
res.writeHead(403).end("forbidden");
|
|
304
422
|
return;
|
|
305
423
|
}
|
|
306
|
-
if (
|
|
307
|
-
filePath =
|
|
424
|
+
if (existsSync3(filePath) && statSync(filePath).isDirectory()) {
|
|
425
|
+
filePath = join3(filePath, "index.html");
|
|
308
426
|
}
|
|
309
|
-
if (!
|
|
427
|
+
if (!existsSync3(filePath)) {
|
|
310
428
|
res.writeHead(404).end(`not found: ${urlPath}`);
|
|
311
429
|
return;
|
|
312
430
|
}
|
|
@@ -395,10 +513,10 @@ async function renderProject(opts) {
|
|
|
395
513
|
const fps = opts.fps ?? 30;
|
|
396
514
|
const crf = opts.draft ? 28 : opts.crf ?? 18;
|
|
397
515
|
const preset = opts.draft ? "veryfast" : "medium";
|
|
398
|
-
const projectDir =
|
|
516
|
+
const projectDir = resolve4(opts.projectDir);
|
|
399
517
|
const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19);
|
|
400
|
-
const out =
|
|
401
|
-
|
|
518
|
+
const out = resolve4(projectDir, opts.out ?? `renders/render_${stamp}.mp4`);
|
|
519
|
+
mkdirSync3(dirname(out), { recursive: true });
|
|
402
520
|
const session = await openSession(projectDir, { echoErrors: true });
|
|
403
521
|
try {
|
|
404
522
|
const { page, info } = session;
|
|
@@ -463,9 +581,9 @@ async function renderProject(opts) {
|
|
|
463
581
|
}
|
|
464
582
|
|
|
465
583
|
// src/scaffold.ts
|
|
466
|
-
import { copyFileSync as copyFileSync2, existsSync as
|
|
584
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
467
585
|
import { createRequire } from "node:module";
|
|
468
|
-
import { dirname as dirname2, join as
|
|
586
|
+
import { dirname as dirname2, join as join4, resolve as resolve5 } from "node:path";
|
|
469
587
|
var TEMPLATE = `<!doctype html>
|
|
470
588
|
<html lang="en">
|
|
471
589
|
<head>
|
|
@@ -508,34 +626,34 @@ function resolveRuntimeBundle() {
|
|
|
508
626
|
return require2.resolve("stereoframe-runtime");
|
|
509
627
|
}
|
|
510
628
|
function scaffoldProject(name, cwd = process.cwd()) {
|
|
511
|
-
const dir =
|
|
512
|
-
if (
|
|
629
|
+
const dir = resolve5(cwd, name);
|
|
630
|
+
if (existsSync4(join4(dir, "index.html"))) {
|
|
513
631
|
throw new Error(`refusing to overwrite existing project at ${dir}`);
|
|
514
632
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
633
|
+
mkdirSync4(join4(dir, "assets"), { recursive: true });
|
|
634
|
+
writeFileSync2(join4(dir, "index.html"), TEMPLATE);
|
|
635
|
+
writeFileSync2(join4(dir, ".gitignore"), `renders/
|
|
518
636
|
`);
|
|
519
|
-
copyFileSync2(resolveRuntimeBundle(),
|
|
637
|
+
copyFileSync2(resolveRuntimeBundle(), join4(dir, "assets", "stereoframe.js"));
|
|
520
638
|
return dir;
|
|
521
639
|
}
|
|
522
640
|
function updateRuntime(projectDir) {
|
|
523
|
-
if (!
|
|
641
|
+
if (!existsSync4(join4(resolve5(projectDir), "index.html"))) {
|
|
524
642
|
throw new Error(`no index.html in ${projectDir} — not a stereoframe project`);
|
|
525
643
|
}
|
|
526
|
-
const target =
|
|
527
|
-
|
|
644
|
+
const target = join4(resolve5(projectDir), "assets", "stereoframe.js");
|
|
645
|
+
mkdirSync4(dirname2(target), { recursive: true });
|
|
528
646
|
copyFileSync2(resolveRuntimeBundle(), target);
|
|
529
647
|
return target;
|
|
530
648
|
}
|
|
531
649
|
|
|
532
650
|
// src/validate.ts
|
|
533
|
-
import { resolve as
|
|
651
|
+
import { resolve as resolve6 } from "node:path";
|
|
534
652
|
async function validateProject(projectDir) {
|
|
535
653
|
const findings = [];
|
|
536
654
|
let session;
|
|
537
655
|
try {
|
|
538
|
-
session = await openSession(
|
|
656
|
+
session = await openSession(resolve6(projectDir));
|
|
539
657
|
} catch (err) {
|
|
540
658
|
return [
|
|
541
659
|
{
|
|
@@ -660,6 +778,12 @@ USAGE
|
|
|
660
778
|
--draft fast low-quality render for iteration
|
|
661
779
|
stereoframe preview [dir] serve with looping wall-clock playback
|
|
662
780
|
--port <n> fixed port (default: random)
|
|
781
|
+
stereoframe gen "<prompt>" generate a 3D model (GLB) from text via Meshy
|
|
782
|
+
--dir <dir> project dir (default .)
|
|
783
|
+
--out <path> output path (default assets/<slug>.glb)
|
|
784
|
+
--no-texture skip the PBR texture pass (faster, untextured mesh)
|
|
785
|
+
--polycount <n> target polygon count
|
|
786
|
+
--key <key> Meshy API key (else MESHY_API_KEY / .env / test mode)
|
|
663
787
|
stereoframe add <block> [dir] install a visual block's assets + print usage
|
|
664
788
|
stereoframe blocks list available blocks
|
|
665
789
|
stereoframe update [dir] refresh assets/stereoframe.js from the CLI's bundled runtime
|
|
@@ -679,11 +803,11 @@ async function main() {
|
|
|
679
803
|
return;
|
|
680
804
|
}
|
|
681
805
|
case "lint": {
|
|
682
|
-
const htmlPath =
|
|
683
|
-
if (!
|
|
684
|
-
throw new Error(`no index.html in ${
|
|
685
|
-
const findings = lintHtml(
|
|
686
|
-
fileExists: (rel) =>
|
|
806
|
+
const htmlPath = join5(resolve7(dir), "index.html");
|
|
807
|
+
if (!existsSync5(htmlPath))
|
|
808
|
+
throw new Error(`no index.html in ${resolve7(dir)}`);
|
|
809
|
+
const findings = lintHtml(readFileSync2(htmlPath, "utf8"), {
|
|
810
|
+
fileExists: (rel) => existsSync5(join5(resolve7(dir), rel))
|
|
687
811
|
});
|
|
688
812
|
reportFindings("lint", findings, options.get("json") === true);
|
|
689
813
|
return;
|
|
@@ -711,6 +835,20 @@ async function main() {
|
|
|
711
835
|
console.log("press ctrl-c to stop");
|
|
712
836
|
return;
|
|
713
837
|
}
|
|
838
|
+
case "gen": {
|
|
839
|
+
const prompt = positional.join(" ").trim();
|
|
840
|
+
if (!prompt)
|
|
841
|
+
throw new Error('usage: stereoframe gen "<prompt>"');
|
|
842
|
+
await genModel({
|
|
843
|
+
prompt,
|
|
844
|
+
projectDir: typeof options.get("dir") === "string" ? options.get("dir") : ".",
|
|
845
|
+
out: typeof options.get("out") === "string" ? options.get("out") : undefined,
|
|
846
|
+
texture: options.get("no-texture") !== true,
|
|
847
|
+
polycount: options.has("polycount") ? Number(options.get("polycount")) : undefined,
|
|
848
|
+
key: typeof options.get("key") === "string" ? options.get("key") : undefined
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
714
852
|
case "add": {
|
|
715
853
|
const name = positional[0];
|
|
716
854
|
if (!name)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stereoframe",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "Declarative, deterministic 3D video on three.js — scaffold, lint, validate, and render compositions built for AI agents.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"test": "bun test"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"stereoframe-runtime": "0.2.
|
|
28
|
+
"stereoframe-runtime": "0.2.4",
|
|
29
29
|
"puppeteer": "^24.0.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|