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.
Files changed (2) hide show
  1. package/dist/cli.js +170 -32
  2. 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 existsSync4 } from "node:fs";
5
- import { join as join4, resolve as resolve6 } from "node:path";
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 mkdirSync2 } from "node:fs";
257
- import { dirname, resolve as resolve3 } from "node:path";
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 existsSync2, statSync } from "node:fs";
265
- import { extname, join as join2, normalize, resolve as resolve2 } from "node:path";
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 = resolve2(projectDir);
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 = join2(root, rel);
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 (existsSync2(filePath) && statSync(filePath).isDirectory()) {
307
- filePath = join2(filePath, "index.html");
424
+ if (existsSync3(filePath) && statSync(filePath).isDirectory()) {
425
+ filePath = join3(filePath, "index.html");
308
426
  }
309
- if (!existsSync2(filePath)) {
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 = resolve3(opts.projectDir);
516
+ const projectDir = resolve4(opts.projectDir);
399
517
  const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19);
400
- const out = resolve3(projectDir, opts.out ?? `renders/render_${stamp}.mp4`);
401
- mkdirSync2(dirname(out), { recursive: true });
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 existsSync3, mkdirSync as mkdirSync3, writeFileSync } from "node:fs";
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 join3, resolve as resolve4 } from "node:path";
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 = resolve4(cwd, name);
512
- if (existsSync3(join3(dir, "index.html"))) {
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
- mkdirSync3(join3(dir, "assets"), { recursive: true });
516
- writeFileSync(join3(dir, "index.html"), TEMPLATE);
517
- writeFileSync(join3(dir, ".gitignore"), `renders/
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(), join3(dir, "assets", "stereoframe.js"));
637
+ copyFileSync2(resolveRuntimeBundle(), join4(dir, "assets", "stereoframe.js"));
520
638
  return dir;
521
639
  }
522
640
  function updateRuntime(projectDir) {
523
- if (!existsSync3(join3(resolve4(projectDir), "index.html"))) {
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 = join3(resolve4(projectDir), "assets", "stereoframe.js");
527
- mkdirSync3(dirname2(target), { recursive: true });
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 resolve5 } from "node:path";
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(resolve5(projectDir));
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 = join4(resolve6(dir), "index.html");
683
- if (!existsSync4(htmlPath))
684
- throw new Error(`no index.html in ${resolve6(dir)}`);
685
- const findings = lintHtml(readFileSync(htmlPath, "utf8"), {
686
- fileExists: (rel) => existsSync4(join4(resolve6(dir), 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.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.2",
28
+ "stereoframe-runtime": "0.2.4",
29
29
  "puppeteer": "^24.0.0"
30
30
  },
31
31
  "devDependencies": {