stereoframe 0.2.6 → 0.2.7

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 +250 -87
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { readFileSync as readFileSync2, existsSync as existsSync5 } from "node:fs";
5
- import { join as join5, resolve as resolve7 } from "node:path";
4
+ import { readFileSync as readFileSync2, existsSync as existsSync6 } from "node:fs";
5
+ import { join as join6, resolve as resolve8 } from "node:path";
6
+ import { basename as basename2 } from "node:path";
6
7
 
7
8
  // src/blocks.ts
8
9
  import { copyFileSync, existsSync, mkdirSync } from "node:fs";
@@ -174,6 +175,205 @@ async function genModel(opts) {
174
175
  return out;
175
176
  }
176
177
 
178
+ // src/stage.ts
179
+ import { copyFileSync as copyFileSync3, existsSync as existsSync4, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "node:fs";
180
+ import { basename, join as join4, resolve as resolve4 } from "node:path";
181
+
182
+ // src/scaffold.ts
183
+ import { copyFileSync as copyFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
184
+ import { createRequire } from "node:module";
185
+ import { dirname, join as join3, resolve as resolve3 } from "node:path";
186
+ var TEMPLATE = `<!doctype html>
187
+ <html lang="en">
188
+ <head>
189
+ <meta charset="UTF-8" />
190
+ <style>
191
+ * { margin: 0; padding: 0; box-sizing: border-box; }
192
+ html, body { width: 1920px; height: 1080px; overflow: hidden; background: #000; }
193
+ body { font-family: ui-sans-serif, system-ui, sans-serif; }
194
+ sf-scene { position: absolute; inset: 0; }
195
+ #title {
196
+ position: absolute; bottom: 100px; width: 100%; text-align: center;
197
+ font-size: 64px; font-weight: 700; color: #f4f4f5; letter-spacing: 0.04em;
198
+ }
199
+ </style>
200
+ </head>
201
+ <body>
202
+ <sf-scene duration="5" width="1920" height="1080" background="#101225">
203
+ <sf-camera fov="38" position="0 0.8 6" look-at="0 0 0"></sf-camera>
204
+ <sf-light preset="soft"></sf-light>
205
+
206
+ <sf-mesh id="hero" geometry="icosahedron" args="1.4 2" color="#7dd3fc"
207
+ metalness="0.4" roughness="0.25"></sf-mesh>
208
+
209
+ <sf-animate target="#hero" verb="bounce-in" start="0.3" duration="0.8"></sf-animate>
210
+ <sf-animate target="#hero" verb="turntable" rpm="10"></sf-animate>
211
+ <sf-animate target="#hero" verb="float" amplitude="0.12" period="3"></sf-animate>
212
+ <sf-animate target="#title" verb="fade-in" start="1.2" duration="0.8" rise="30"></sf-animate>
213
+ </sf-scene>
214
+
215
+ <div id="title" class="clip" data-start="1.2">hello stereoframe</div>
216
+
217
+ <script type="module">
218
+ import "./assets/stereoframe.js";
219
+ </script>
220
+ </body>
221
+ </html>
222
+ `;
223
+ function resolveRuntimeBundle() {
224
+ const require2 = createRequire(import.meta.url);
225
+ return require2.resolve("stereoframe-runtime");
226
+ }
227
+ function scaffoldProject(name, cwd = process.cwd()) {
228
+ const dir = resolve3(cwd, name);
229
+ if (existsSync3(join3(dir, "index.html"))) {
230
+ throw new Error(`refusing to overwrite existing project at ${dir}`);
231
+ }
232
+ mkdirSync3(join3(dir, "assets"), { recursive: true });
233
+ writeFileSync2(join3(dir, "index.html"), TEMPLATE);
234
+ writeFileSync2(join3(dir, ".gitignore"), `renders/
235
+ `);
236
+ copyFileSync2(resolveRuntimeBundle(), join3(dir, "assets", "stereoframe.js"));
237
+ return dir;
238
+ }
239
+ function updateRuntime(projectDir) {
240
+ if (!existsSync3(join3(resolve3(projectDir), "index.html"))) {
241
+ throw new Error(`no index.html in ${projectDir} — not a stereoframe project`);
242
+ }
243
+ const target = join3(resolve3(projectDir), "assets", "stereoframe.js");
244
+ mkdirSync3(dirname(target), { recursive: true });
245
+ copyFileSync2(resolveRuntimeBundle(), target);
246
+ return target;
247
+ }
248
+
249
+ // src/stage.ts
250
+ var PRESETS = ["reveal", "hero-orbit", "turntable"];
251
+ function head(bg) {
252
+ return `<!doctype html>
253
+ <html lang="en">
254
+ <head>
255
+ <meta charset="UTF-8" />
256
+ <style>
257
+ * { margin: 0; padding: 0; box-sizing: border-box; }
258
+ html, body { width: 1920px; height: 1080px; overflow: hidden; background: ${bg}; }
259
+ body { font-family: "Helvetica Neue", Arial, sans-serif; }
260
+ sf-scene { position: absolute; inset: 0; }
261
+ #title {
262
+ position: absolute; bottom: 110px; width: 100%; text-align: center;
263
+ font-size: 70px; font-weight: 700; letter-spacing: 0.04em; color: #f4f1e8;
264
+ text-shadow: 0 4px 30px rgba(0,0,0,0.55);
265
+ }
266
+ </style>
267
+ </head>
268
+ <body>`;
269
+ }
270
+ var tail = `
271
+ <script type="module">
272
+ import "./assets/stereoframe.js";
273
+ </script>
274
+ </body>
275
+ </html>
276
+ `;
277
+ function titleBlock(title, t1) {
278
+ if (!title)
279
+ return { anim: "", dom: "" };
280
+ return {
281
+ anim: ` <sf-animate target="#title" verb="fade-in" start="${t1}" duration="1.2" rise="34"></sf-animate>
282
+ `,
283
+ dom: ` <div id="title" class="clip" data-start="${t1}">${title}</div>
284
+ `
285
+ };
286
+ }
287
+ function reveal(model, d, bg, title) {
288
+ const t1 = Math.max(1.2, d * 0.4);
289
+ const t = titleBlock(title, t1);
290
+ return `${head(bg)}
291
+ <sf-scene duration="${d}" width="1920" height="1080" background="${bg}"
292
+ environment="room" exposure="0.92"
293
+ samples="2" bloom="0.22" bloom-threshold="0.86" vignette="0.45"
294
+ chromatic-aberration="0.14" grain="0.03" contrast="1.05" saturation="1.05">
295
+ <sf-camera fov="34" position="-3 -0.8 5.2" look-at="0 0.1 0"></sf-camera>
296
+ <sf-light type="hemisphere" color="#2a3450" intensity="0.5"></sf-light>
297
+ <sf-light type="directional" color="#ffffff" intensity="2.6" position="4 6 5"></sf-light>
298
+ <sf-light type="directional" color="#9db8ff" intensity="1.5" position="-5 2 -4"></sf-light>
299
+ <sf-model id="m" src="assets/${model}" fit="2.6"></sf-model>
300
+ <sf-animate target="#m" verb="bounce-in" start="0.2" duration="1" ease="back.out"></sf-animate>
301
+ <sf-animate target="#m" verb="turntable" rpm="2.4" start="0.5"></sf-animate>
302
+ <sf-animate target="camera" verb="camera-path" look="none"
303
+ points="-3 -0.8 5.2, -1.8 0 4.4, -0.7 0.5 3.9, 0.25 0.7 3.7"
304
+ start="0" duration="${d}" ease="power2.inOut"></sf-animate>
305
+ ${t.anim} </sf-scene>
306
+ ${t.dom}${tail}`;
307
+ }
308
+ function heroOrbit(model, d, bg, title) {
309
+ const t1 = Math.max(1.2, d * 0.4);
310
+ const t = titleBlock(title, t1);
311
+ return `${head(bg)}
312
+ <sf-scene duration="${d}" width="1920" height="1080" background="${bg}"
313
+ environment="room" exposure="1.0"
314
+ samples="2" bloom="0.12" bloom-threshold="0.9" vignette="0.32"
315
+ grain="0.025" contrast="1.03" saturation="1.05">
316
+ <sf-camera fov="34" position="0 0.6 5" look-at="0 0 0"></sf-camera>
317
+ <sf-light preset="studio"></sf-light>
318
+ <sf-model id="m" src="assets/${model}" fit="2.6"></sf-model>
319
+ <sf-animate target="#m" verb="turntable" rpm="1.4"></sf-animate>
320
+ <sf-animate target="camera" verb="orbit" around="0 0 0" radius="5"
321
+ from="-38deg" to="38deg" height="0.6" start="0" duration="${d}"
322
+ ease="sine.inOut"></sf-animate>
323
+ ${t.anim} </sf-scene>
324
+ ${t.dom}${tail}`;
325
+ }
326
+ function turntable(model, d, bg, title) {
327
+ const t1 = Math.max(1.2, d * 0.4);
328
+ const t = titleBlock(title, t1);
329
+ return `${head(bg)}
330
+ <sf-scene duration="${d}" width="1920" height="1080" background="${bg}"
331
+ environment="room" exposure="1.02"
332
+ samples="2" bloom="0.12" bloom-threshold="0.9" vignette="0.3"
333
+ grain="0.02" contrast="1.03">
334
+ <sf-camera fov="33" position="0 1.4 5.4" look-at="0 1 0"></sf-camera>
335
+ <sf-light preset="studio"></sf-light>
336
+ <sf-mesh geometry="cylinder" args="2 2.2 0.12" color="#1c1f26"
337
+ roughness="0.3" metalness="0.6" position="0 -0.06 0"></sf-mesh>
338
+ <sf-model id="m" src="assets/${model}" fit="2.3" fit-ground></sf-model>
339
+ <sf-animate target="#m" verb="turntable" rpm="5"></sf-animate>
340
+ <sf-animate target="camera" verb="dolly" toward="0 1 0" distance="0.5"
341
+ start="0" duration="${d}" ease="sine.inOut"></sf-animate>
342
+ ${t.anim} </sf-scene>
343
+ ${t.dom}${tail}`;
344
+ }
345
+ var TEMPLATES = {
346
+ reveal,
347
+ "hero-orbit": heroOrbit,
348
+ turntable
349
+ };
350
+ var DEFAULT_BG = {
351
+ reveal: "#0a0a0e",
352
+ "hero-orbit": "#16181c",
353
+ turntable: "#15171b"
354
+ };
355
+ function stageModel(opts) {
356
+ const modelPath = resolve4(opts.model);
357
+ if (!existsSync4(modelPath))
358
+ throw new Error(`model not found: ${opts.model}`);
359
+ if (!/\.(glb|gltf)$/i.test(modelPath)) {
360
+ throw new Error("stage expects a .glb or .gltf model");
361
+ }
362
+ const dir = resolve4(opts.projectDir);
363
+ mkdirSync4(join4(dir, "assets"), { recursive: true });
364
+ const modelFile = basename(modelPath);
365
+ copyFileSync3(modelPath, join4(dir, "assets", modelFile));
366
+ copyFileSync3(resolveRuntimeBundle(), join4(dir, "assets", "stereoframe.js"));
367
+ if (!existsSync4(join4(dir, ".gitignore")))
368
+ writeFileSync3(join4(dir, ".gitignore"), `renders/
369
+ `);
370
+ const d = opts.duration && opts.duration > 0 ? opts.duration : 8;
371
+ const bg = opts.background ?? DEFAULT_BG[opts.preset];
372
+ const html = TEMPLATES[opts.preset](modelFile, d, bg, opts.title);
373
+ writeFileSync3(join4(dir, "index.html"), html);
374
+ return dir;
375
+ }
376
+
177
377
  // src/lint.ts
178
378
  import { ASSET_ATTRS, EASE_NAMES, ELEMENT_NAMES, VERB_NAMES } from "stereoframe-runtime/vocab";
179
379
  function readAttr(attrs, name) {
@@ -371,16 +571,16 @@ function lintHtml(html, opts) {
371
571
 
372
572
  // src/render.ts
373
573
  import { spawn } from "node:child_process";
374
- import { mkdirSync as mkdirSync3 } from "node:fs";
375
- import { dirname, resolve as resolve4 } from "node:path";
574
+ import { mkdirSync as mkdirSync5 } from "node:fs";
575
+ import { dirname as dirname2, resolve as resolve6 } from "node:path";
376
576
 
377
577
  // src/session.ts
378
578
  import puppeteer from "puppeteer";
379
579
 
380
580
  // src/serve.ts
381
581
  import { createServer } from "node:http";
382
- import { createReadStream, existsSync as existsSync3, statSync } from "node:fs";
383
- import { extname, join as join3, normalize, resolve as resolve3 } from "node:path";
582
+ import { createReadStream, existsSync as existsSync5, statSync } from "node:fs";
583
+ import { extname, join as join5, normalize, resolve as resolve5 } from "node:path";
384
584
  var MIME = {
385
585
  ".html": "text/html; charset=utf-8",
386
586
  ".js": "text/javascript; charset=utf-8",
@@ -408,7 +608,7 @@ var MIME = {
408
608
  ".otf": "font/otf"
409
609
  };
410
610
  function serveProject(projectDir, fixedPort = 0) {
411
- const root = resolve3(projectDir);
611
+ const root = resolve5(projectDir);
412
612
  const server = createServer((req, res) => {
413
613
  const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
414
614
  if (urlPath === "/favicon.ico") {
@@ -416,15 +616,15 @@ function serveProject(projectDir, fixedPort = 0) {
416
616
  return;
417
617
  }
418
618
  const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
419
- let filePath = join3(root, rel);
619
+ let filePath = join5(root, rel);
420
620
  if (!filePath.startsWith(root)) {
421
621
  res.writeHead(403).end("forbidden");
422
622
  return;
423
623
  }
424
- if (existsSync3(filePath) && statSync(filePath).isDirectory()) {
425
- filePath = join3(filePath, "index.html");
624
+ if (existsSync5(filePath) && statSync(filePath).isDirectory()) {
625
+ filePath = join5(filePath, "index.html");
426
626
  }
427
- if (!existsSync3(filePath)) {
627
+ if (!existsSync5(filePath)) {
428
628
  res.writeHead(404).end(`not found: ${urlPath}`);
429
629
  return;
430
630
  }
@@ -513,10 +713,10 @@ async function renderProject(opts) {
513
713
  const fps = opts.fps ?? 30;
514
714
  const crf = opts.draft ? 28 : opts.crf ?? 18;
515
715
  const preset = opts.draft ? "veryfast" : "medium";
516
- const projectDir = resolve4(opts.projectDir);
716
+ const projectDir = resolve6(opts.projectDir);
517
717
  const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19);
518
- const out = resolve4(projectDir, opts.out ?? `renders/render_${stamp}.mp4`);
519
- mkdirSync3(dirname(out), { recursive: true });
718
+ const out = resolve6(projectDir, opts.out ?? `renders/render_${stamp}.mp4`);
719
+ mkdirSync5(dirname2(out), { recursive: true });
520
720
  const session = await openSession(projectDir, { echoErrors: true });
521
721
  try {
522
722
  const { page, info } = session;
@@ -580,80 +780,13 @@ async function renderProject(opts) {
580
780
  }
581
781
  }
582
782
 
583
- // src/scaffold.ts
584
- import { copyFileSync as copyFileSync2, existsSync as existsSync4, mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "node:fs";
585
- import { createRequire } from "node:module";
586
- import { dirname as dirname2, join as join4, resolve as resolve5 } from "node:path";
587
- var TEMPLATE = `<!doctype html>
588
- <html lang="en">
589
- <head>
590
- <meta charset="UTF-8" />
591
- <style>
592
- * { margin: 0; padding: 0; box-sizing: border-box; }
593
- html, body { width: 1920px; height: 1080px; overflow: hidden; background: #000; }
594
- body { font-family: ui-sans-serif, system-ui, sans-serif; }
595
- sf-scene { position: absolute; inset: 0; }
596
- #title {
597
- position: absolute; bottom: 100px; width: 100%; text-align: center;
598
- font-size: 64px; font-weight: 700; color: #f4f4f5; letter-spacing: 0.04em;
599
- }
600
- </style>
601
- </head>
602
- <body>
603
- <sf-scene duration="5" width="1920" height="1080" background="#101225">
604
- <sf-camera fov="38" position="0 0.8 6" look-at="0 0 0"></sf-camera>
605
- <sf-light preset="soft"></sf-light>
606
-
607
- <sf-mesh id="hero" geometry="icosahedron" args="1.4 2" color="#7dd3fc"
608
- metalness="0.4" roughness="0.25"></sf-mesh>
609
-
610
- <sf-animate target="#hero" verb="bounce-in" start="0.3" duration="0.8"></sf-animate>
611
- <sf-animate target="#hero" verb="turntable" rpm="10"></sf-animate>
612
- <sf-animate target="#hero" verb="float" amplitude="0.12" period="3"></sf-animate>
613
- <sf-animate target="#title" verb="fade-in" start="1.2" duration="0.8" rise="30"></sf-animate>
614
- </sf-scene>
615
-
616
- <div id="title" class="clip" data-start="1.2">hello stereoframe</div>
617
-
618
- <script type="module">
619
- import "./assets/stereoframe.js";
620
- </script>
621
- </body>
622
- </html>
623
- `;
624
- function resolveRuntimeBundle() {
625
- const require2 = createRequire(import.meta.url);
626
- return require2.resolve("stereoframe-runtime");
627
- }
628
- function scaffoldProject(name, cwd = process.cwd()) {
629
- const dir = resolve5(cwd, name);
630
- if (existsSync4(join4(dir, "index.html"))) {
631
- throw new Error(`refusing to overwrite existing project at ${dir}`);
632
- }
633
- mkdirSync4(join4(dir, "assets"), { recursive: true });
634
- writeFileSync2(join4(dir, "index.html"), TEMPLATE);
635
- writeFileSync2(join4(dir, ".gitignore"), `renders/
636
- `);
637
- copyFileSync2(resolveRuntimeBundle(), join4(dir, "assets", "stereoframe.js"));
638
- return dir;
639
- }
640
- function updateRuntime(projectDir) {
641
- if (!existsSync4(join4(resolve5(projectDir), "index.html"))) {
642
- throw new Error(`no index.html in ${projectDir} — not a stereoframe project`);
643
- }
644
- const target = join4(resolve5(projectDir), "assets", "stereoframe.js");
645
- mkdirSync4(dirname2(target), { recursive: true });
646
- copyFileSync2(resolveRuntimeBundle(), target);
647
- return target;
648
- }
649
-
650
783
  // src/validate.ts
651
- import { resolve as resolve6 } from "node:path";
784
+ import { resolve as resolve7 } from "node:path";
652
785
  async function validateProject(projectDir) {
653
786
  const findings = [];
654
787
  let session;
655
788
  try {
656
- session = await openSession(resolve6(projectDir));
789
+ session = await openSession(resolve7(projectDir));
657
790
  } catch (err) {
658
791
  return [
659
792
  {
@@ -778,6 +911,12 @@ USAGE
778
911
  --draft fast low-quality render for iteration
779
912
  stereoframe preview [dir] serve with looping wall-clock playback
780
913
  --port <n> fixed port (default: random)
914
+ stereoframe stage <model.glb> auto-direct a GLB into a cinematic motion graphic
915
+ --preset <name> reveal | hero-orbit | turntable (default reveal)
916
+ --dir <dir> output project dir (default: <model name>)
917
+ --duration <s> seconds (default 8)
918
+ --title "<text>" optional title overlay
919
+ --bg <color> background color (preset default otherwise)
781
920
  stereoframe gen "<prompt>" generate a 3D model (GLB) from text via Meshy
782
921
  --dir <dir> project dir (default .)
783
922
  --out <path> output path (default assets/<slug>.glb)
@@ -803,11 +942,11 @@ async function main() {
803
942
  return;
804
943
  }
805
944
  case "lint": {
806
- const htmlPath = join5(resolve7(dir), "index.html");
807
- if (!existsSync5(htmlPath))
808
- throw new Error(`no index.html in ${resolve7(dir)}`);
945
+ const htmlPath = join6(resolve8(dir), "index.html");
946
+ if (!existsSync6(htmlPath))
947
+ throw new Error(`no index.html in ${resolve8(dir)}`);
809
948
  const findings = lintHtml(readFileSync2(htmlPath, "utf8"), {
810
- fileExists: (rel) => existsSync5(join5(resolve7(dir), rel))
949
+ fileExists: (rel) => existsSync6(join6(resolve8(dir), rel))
811
950
  });
812
951
  reportFindings("lint", findings, options.get("json") === true);
813
952
  return;
@@ -835,6 +974,30 @@ async function main() {
835
974
  console.log("press ctrl-c to stop");
836
975
  return;
837
976
  }
977
+ case "stage": {
978
+ const model = positional[0];
979
+ if (!model)
980
+ throw new Error("usage: stereoframe stage <model.glb> [--preset reveal|hero-orbit|turntable]");
981
+ const preset = typeof options.get("preset") === "string" ? options.get("preset") : "reveal";
982
+ if (!PRESETS.includes(preset)) {
983
+ throw new Error(`unknown preset: ${preset}
984
+
985
+ presets: ${PRESETS.join(", ")}`);
986
+ }
987
+ const stem = basename2(model).replace(/\.(glb|gltf)$/i, "");
988
+ const outDir = typeof options.get("dir") === "string" ? options.get("dir") : `${stem}-${preset}`;
989
+ const created = stageModel({
990
+ model,
991
+ projectDir: outDir,
992
+ preset,
993
+ duration: options.has("duration") ? Number(options.get("duration")) : undefined,
994
+ background: typeof options.get("bg") === "string" ? options.get("bg") : undefined,
995
+ title: typeof options.get("title") === "string" ? options.get("title") : undefined
996
+ });
997
+ console.log(`staged ${model} (${preset}) → ${created}`);
998
+ console.log(`next: cd ${outDir} && stereoframe render`);
999
+ return;
1000
+ }
838
1001
  case "gen": {
839
1002
  const prompt = positional.join(" ").trim();
840
1003
  if (!prompt)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stereoframe",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
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.6",
28
+ "stereoframe-runtime": "0.2.7",
29
29
  "puppeteer": "^24.0.0"
30
30
  },
31
31
  "devDependencies": {