stereoframe 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/assets/waternormals.jpg +0 -0
- package/dist/cli.js +761 -0
- package/package.json +33 -0
|
Binary file
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync, existsSync as existsSync4 } from "node:fs";
|
|
5
|
+
import { join as join4, resolve as resolve6 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/blocks.ts
|
|
8
|
+
import { copyFileSync, existsSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
var BLOCKS = {
|
|
12
|
+
ocean: {
|
|
13
|
+
description: "animated water plane (three.js Water) — reflections, sun glint",
|
|
14
|
+
assets: ["waternormals.jpg"],
|
|
15
|
+
snippet: ` <sf-ocean size="2000" color="#001e0f" speed="1"></sf-ocean>
|
|
16
|
+
<!-- pair with <sf-sky> (its sun drives the water highlights) and
|
|
17
|
+
<sf-camera far="5000"> so the horizon isn't clipped -->`
|
|
18
|
+
},
|
|
19
|
+
sky: {
|
|
20
|
+
description: "physical atmosphere dome (three.js Sky) — sun by elevation/azimuth",
|
|
21
|
+
assets: [],
|
|
22
|
+
snippet: ` <sf-sky elevation="12" azimuth="200" turbidity="8"></sf-sky>
|
|
23
|
+
<!-- low elevation (2-15) = golden hour; set <sf-scene exposure="0.6"> for balance -->`
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
function packagedAssetPath(file) {
|
|
27
|
+
return fileURLToPath(new URL(`../assets/${file}`, import.meta.url));
|
|
28
|
+
}
|
|
29
|
+
function listBlocks() {
|
|
30
|
+
return Object.entries(BLOCKS).map(([name, def]) => ` ${name.padEnd(8)} ${def.description}`).join(`
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
function addBlock(name, projectDir) {
|
|
34
|
+
const block = BLOCKS[name];
|
|
35
|
+
if (!block) {
|
|
36
|
+
throw new Error(`unknown block: ${name}
|
|
37
|
+
|
|
38
|
+
available blocks:
|
|
39
|
+
${listBlocks()}`);
|
|
40
|
+
}
|
|
41
|
+
const assetsDir = join(resolve(projectDir), "assets");
|
|
42
|
+
mkdirSync(assetsDir, { recursive: true });
|
|
43
|
+
const copied = [];
|
|
44
|
+
for (const asset of block.assets) {
|
|
45
|
+
const src = packagedAssetPath(asset);
|
|
46
|
+
if (!existsSync(src))
|
|
47
|
+
throw new Error(`packaged asset missing: ${src}`);
|
|
48
|
+
copyFileSync(src, join(assetsDir, asset));
|
|
49
|
+
copied.push(`assets/${asset}`);
|
|
50
|
+
}
|
|
51
|
+
const lines = [
|
|
52
|
+
copied.length > 0 ? `installed: ${copied.join(", ")}` : "no assets needed",
|
|
53
|
+
"",
|
|
54
|
+
"add inside <sf-scene>:",
|
|
55
|
+
block.snippet
|
|
56
|
+
];
|
|
57
|
+
return lines.join(`
|
|
58
|
+
`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/lint.ts
|
|
62
|
+
import { ASSET_ATTRS, EASE_NAMES, ELEMENT_NAMES, VERB_NAMES } from "stereoframe-runtime/vocab";
|
|
63
|
+
function readAttr(attrs, name) {
|
|
64
|
+
const m = attrs.match(new RegExp(`(?:^|\\s)${name}\\s*=\\s*"([^"]*)"`));
|
|
65
|
+
return m ? m[1] : null;
|
|
66
|
+
}
|
|
67
|
+
function sfTags(html) {
|
|
68
|
+
const tags = [];
|
|
69
|
+
const re = /<(sf-[a-z-]+)((?:\s[^>]*)?)>/gi;
|
|
70
|
+
for (const m of html.matchAll(re)) {
|
|
71
|
+
tags.push({ name: m[1].toLowerCase(), attrs: m[2] ?? "" });
|
|
72
|
+
}
|
|
73
|
+
return tags;
|
|
74
|
+
}
|
|
75
|
+
function inlineScripts(html) {
|
|
76
|
+
const scripts = [];
|
|
77
|
+
const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
78
|
+
for (const m of html.matchAll(re)) {
|
|
79
|
+
scripts.push({ attrs: m[1] ?? "", content: m[2] ?? "" });
|
|
80
|
+
}
|
|
81
|
+
return scripts;
|
|
82
|
+
}
|
|
83
|
+
var IMPURE_PATTERNS = [
|
|
84
|
+
{ pattern: /\bDate\.now\s*\(/, what: "Date.now()" },
|
|
85
|
+
{ pattern: /\bperformance\.now\s*\(/, what: "performance.now()" },
|
|
86
|
+
{ pattern: /\bMath\.random\s*\(/, what: "Math.random()" },
|
|
87
|
+
{ pattern: /\brequestAnimationFrame\s*\(/, what: "requestAnimationFrame()" }
|
|
88
|
+
];
|
|
89
|
+
function lintHtml(html, opts) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
const tags = sfTags(html);
|
|
92
|
+
const scenes = tags.filter((t) => t.name === "sf-scene");
|
|
93
|
+
const embed = /data-composition-id\s*=/.test(html);
|
|
94
|
+
if (scenes.length === 0) {
|
|
95
|
+
findings.push({
|
|
96
|
+
rule: "missing_scene",
|
|
97
|
+
severity: "error",
|
|
98
|
+
message: "no <sf-scene> found — nothing to render.",
|
|
99
|
+
fixHint: 'Add an <sf-scene duration="<seconds>"> root with content elements inside.'
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const scene of scenes) {
|
|
103
|
+
if (!readAttr(scene.attrs, "duration") && !embed) {
|
|
104
|
+
findings.push({
|
|
105
|
+
rule: "missing_duration",
|
|
106
|
+
severity: "error",
|
|
107
|
+
message: "<sf-scene> has no duration attribute — the runtime will fall back to 5s.",
|
|
108
|
+
fixHint: 'Add duration="<seconds>" to every <sf-scene>.'
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const knownElements = new Set(ELEMENT_NAMES);
|
|
113
|
+
for (const tag of tags) {
|
|
114
|
+
if (!knownElements.has(tag.name)) {
|
|
115
|
+
findings.push({
|
|
116
|
+
rule: "unknown_element",
|
|
117
|
+
severity: "warning",
|
|
118
|
+
message: `<${tag.name}> is not a stereoframe element (typo?).`,
|
|
119
|
+
fixHint: `Known elements: ${ELEMENT_NAMES.join(", ")}.`
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const tag of tags) {
|
|
124
|
+
for (const attr of ASSET_ATTRS) {
|
|
125
|
+
const value = readAttr(tag.attrs, attr);
|
|
126
|
+
if (!value)
|
|
127
|
+
continue;
|
|
128
|
+
if (/^https?:\/\//i.test(value)) {
|
|
129
|
+
findings.push({
|
|
130
|
+
rule: "remote_asset",
|
|
131
|
+
severity: "error",
|
|
132
|
+
message: `<${tag.name} ${attr}="${value}"> fetches a remote asset — renders must be network-free.`,
|
|
133
|
+
fixHint: "Download the asset into assets/ and reference it relatively."
|
|
134
|
+
});
|
|
135
|
+
} else if (!value.startsWith("data:") && !opts.fileExists(value)) {
|
|
136
|
+
findings.push({
|
|
137
|
+
rule: "asset_not_found",
|
|
138
|
+
severity: "error",
|
|
139
|
+
message: `<${tag.name} ${attr}="${value}">: file not found in the project.`,
|
|
140
|
+
fixHint: "Check the path, or install block assets with `stereoframe add <block>`."
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (!embed) {
|
|
146
|
+
const hasImport = /import\s+["'][^"']*stereoframe(?:\.js)?["']/.test(html) || /<script[^>]*src="[^"]*stereoframe\.js"/.test(html);
|
|
147
|
+
if (!hasImport) {
|
|
148
|
+
findings.push({
|
|
149
|
+
rule: "missing_runtime_import",
|
|
150
|
+
severity: "error",
|
|
151
|
+
message: "the stereoframe runtime is never loaded — the scene will not render.",
|
|
152
|
+
fixHint: 'Add <script type="module">import "./assets/stereoframe.js";</script> before </body>.'
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const script of inlineScripts(html)) {
|
|
157
|
+
if (/\bsrc\s*=/.test(script.attrs))
|
|
158
|
+
continue;
|
|
159
|
+
for (const { pattern, what } of IMPURE_PATTERNS) {
|
|
160
|
+
if (pattern.test(script.content)) {
|
|
161
|
+
findings.push({
|
|
162
|
+
rule: "time_impurity",
|
|
163
|
+
severity: "error",
|
|
164
|
+
message: `inline script uses ${what} — output would differ between runs/frames.`,
|
|
165
|
+
fixHint: "Derive everything from seek time: use sf.onSeek((t) => …) and seeded data (sf-particles seed=…)."
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const knownVerbs = new Set(VERB_NAMES);
|
|
171
|
+
const knownEases = new Set(EASE_NAMES);
|
|
172
|
+
const animates = tags.filter((t) => t.name === "sf-animate");
|
|
173
|
+
for (const el of animates) {
|
|
174
|
+
const verb = (readAttr(el.attrs, "verb") ?? "").toLowerCase();
|
|
175
|
+
if (verb && !knownVerbs.has(verb)) {
|
|
176
|
+
findings.push({
|
|
177
|
+
rule: "unknown_verb",
|
|
178
|
+
severity: "error",
|
|
179
|
+
message: `sf-animate verb="${verb}" is not a known verb.`,
|
|
180
|
+
fixHint: `Known verbs: ${VERB_NAMES.join(", ")}.`
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const ease = readAttr(el.attrs, "ease");
|
|
184
|
+
if (ease && !knownEases.has(ease.trim())) {
|
|
185
|
+
findings.push({
|
|
186
|
+
rule: "unknown_ease",
|
|
187
|
+
severity: "warning",
|
|
188
|
+
message: `ease="${ease}" is not a known easing name — it will fall back to the default.`,
|
|
189
|
+
fixHint: "Use GSAP-style names like power2.inOut, back.out, sine.inOut, linear."
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const target = readAttr(el.attrs, "target");
|
|
193
|
+
if (target?.startsWith("#")) {
|
|
194
|
+
const id = target.slice(1);
|
|
195
|
+
if (!new RegExp(`\\bid\\s*=\\s*"${id}"`).test(html)) {
|
|
196
|
+
findings.push({
|
|
197
|
+
rule: "verb_target_missing",
|
|
198
|
+
severity: "error",
|
|
199
|
+
message: `sf-animate target="${target}" matches no element id in the document.`
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const sceneBlocks = html.match(/<sf-scene\b[^>]*>[\s\S]*?<\/sf-scene>/gi) ?? [];
|
|
205
|
+
for (const block of sceneBlocks) {
|
|
206
|
+
const blockTags = sfTags(block);
|
|
207
|
+
const ahead = blockTags.some((t) => t.name === "sf-animate" && (readAttr(t.attrs, "verb") ?? "").toLowerCase() === "camera-path" && (readAttr(t.attrs, "look") ?? "ahead") !== "none");
|
|
208
|
+
const lookAt = blockTags.some((t) => t.name === "sf-camera" && readAttr(t.attrs, "look-at") !== null);
|
|
209
|
+
if (ahead && lookAt) {
|
|
210
|
+
findings.push({
|
|
211
|
+
rule: "camera_path_lookat_conflict",
|
|
212
|
+
severity: "warning",
|
|
213
|
+
message: 'camera-path look="ahead" is overridden by sf-camera look-at in the same scene (look-at is applied after path aiming).',
|
|
214
|
+
fixHint: 'Remove look-at from that sf-camera, or set look="none" on the camera-path.'
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const clipRe = /<(?!sf-)([a-z][a-z0-9]*)\b([^>]*\bdata-start\s*=[^>]*)>/gi;
|
|
219
|
+
for (const m of html.matchAll(clipRe)) {
|
|
220
|
+
const attrs = m[2] ?? "";
|
|
221
|
+
const cls = readAttr(attrs, "class") ?? "";
|
|
222
|
+
if (!cls.split(/\s+/).includes("clip")) {
|
|
223
|
+
findings.push({
|
|
224
|
+
rule: "dom_clip_missing_class",
|
|
225
|
+
severity: "warning",
|
|
226
|
+
message: `<${m[1]}> has data-start but no class="clip" — its visibility window will be ignored.`,
|
|
227
|
+
fixHint: 'Add class="clip" so the runtime drives its visibility from seek time.'
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const shots = scenes.map((s) => ({
|
|
232
|
+
start: Number(readAttr(s.attrs, "start") ?? 0) || 0,
|
|
233
|
+
duration: Number(readAttr(s.attrs, "duration") ?? 5) || 5,
|
|
234
|
+
transition: (readAttr(s.attrs, "transition") ?? "cut").toLowerCase(),
|
|
235
|
+
transitionDuration: Number(readAttr(s.attrs, "transition-duration") ?? 0.6) || 0.6
|
|
236
|
+
}));
|
|
237
|
+
for (let i = 1;i < shots.length; i++) {
|
|
238
|
+
const shot = shots[i];
|
|
239
|
+
if (shot.transition !== "crossfade")
|
|
240
|
+
continue;
|
|
241
|
+
const priorEnd = Math.max(...shots.slice(0, i).map((p) => p.start + p.duration));
|
|
242
|
+
if (priorEnd < shot.start + shot.transitionDuration - 0.000001) {
|
|
243
|
+
findings.push({
|
|
244
|
+
rule: "transition_gap",
|
|
245
|
+
severity: "warning",
|
|
246
|
+
message: `shot ${i + 1} crossfades until t=${(shot.start + shot.transitionDuration).toFixed(2)} but the previous shot ends at t=${priorEnd.toFixed(2)} — the fade will show the page background.`,
|
|
247
|
+
fixHint: "Extend the previous shot's duration to cover start + transition-duration."
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return findings;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// src/render.ts
|
|
255
|
+
import { spawn } from "node:child_process";
|
|
256
|
+
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
257
|
+
import { dirname, resolve as resolve3 } from "node:path";
|
|
258
|
+
|
|
259
|
+
// src/session.ts
|
|
260
|
+
import puppeteer from "puppeteer";
|
|
261
|
+
|
|
262
|
+
// src/serve.ts
|
|
263
|
+
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";
|
|
266
|
+
var MIME = {
|
|
267
|
+
".html": "text/html; charset=utf-8",
|
|
268
|
+
".js": "text/javascript; charset=utf-8",
|
|
269
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
270
|
+
".css": "text/css; charset=utf-8",
|
|
271
|
+
".json": "application/json",
|
|
272
|
+
".png": "image/png",
|
|
273
|
+
".jpg": "image/jpeg",
|
|
274
|
+
".jpeg": "image/jpeg",
|
|
275
|
+
".webp": "image/webp",
|
|
276
|
+
".gif": "image/gif",
|
|
277
|
+
".svg": "image/svg+xml",
|
|
278
|
+
".glb": "model/gltf-binary",
|
|
279
|
+
".gltf": "model/gltf+json",
|
|
280
|
+
".hdr": "application/octet-stream",
|
|
281
|
+
".exr": "application/octet-stream",
|
|
282
|
+
".bin": "application/octet-stream",
|
|
283
|
+
".ktx2": "application/octet-stream",
|
|
284
|
+
".mp4": "video/mp4",
|
|
285
|
+
".mp3": "audio/mpeg",
|
|
286
|
+
".wav": "audio/wav",
|
|
287
|
+
".woff": "font/woff",
|
|
288
|
+
".woff2": "font/woff2",
|
|
289
|
+
".ttf": "font/ttf",
|
|
290
|
+
".otf": "font/otf"
|
|
291
|
+
};
|
|
292
|
+
function serveProject(projectDir, fixedPort = 0) {
|
|
293
|
+
const root = resolve2(projectDir);
|
|
294
|
+
const server = createServer((req, res) => {
|
|
295
|
+
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
|
|
296
|
+
if (urlPath === "/favicon.ico") {
|
|
297
|
+
res.writeHead(204).end();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const rel = normalize(urlPath).replace(/^(\.\.[/\\])+/, "");
|
|
301
|
+
let filePath = join2(root, rel);
|
|
302
|
+
if (!filePath.startsWith(root)) {
|
|
303
|
+
res.writeHead(403).end("forbidden");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (existsSync2(filePath) && statSync(filePath).isDirectory()) {
|
|
307
|
+
filePath = join2(filePath, "index.html");
|
|
308
|
+
}
|
|
309
|
+
if (!existsSync2(filePath)) {
|
|
310
|
+
res.writeHead(404).end(`not found: ${urlPath}`);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
res.writeHead(200, {
|
|
314
|
+
"content-type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
|
|
315
|
+
"cache-control": "no-store"
|
|
316
|
+
});
|
|
317
|
+
createReadStream(filePath).pipe(res);
|
|
318
|
+
});
|
|
319
|
+
return new Promise((resolvePromise, reject) => {
|
|
320
|
+
server.once("error", reject);
|
|
321
|
+
server.listen(fixedPort, "127.0.0.1", () => {
|
|
322
|
+
const address = server.address();
|
|
323
|
+
const port = typeof address === "object" && address ? address.port : fixedPort;
|
|
324
|
+
resolvePromise({
|
|
325
|
+
server,
|
|
326
|
+
port,
|
|
327
|
+
url: `http://127.0.0.1:${port}/`,
|
|
328
|
+
close: () => new Promise((r) => {
|
|
329
|
+
server.close(() => r());
|
|
330
|
+
})
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// src/session.ts
|
|
337
|
+
async function openSession(projectDir, opts = {}) {
|
|
338
|
+
const handle = await serveProject(projectDir);
|
|
339
|
+
const browser = await puppeteer.launch({
|
|
340
|
+
headless: true,
|
|
341
|
+
args: [
|
|
342
|
+
"--force-device-scale-factor=1",
|
|
343
|
+
"--hide-scrollbars",
|
|
344
|
+
"--enable-unsafe-swiftshader"
|
|
345
|
+
]
|
|
346
|
+
});
|
|
347
|
+
try {
|
|
348
|
+
const page = await browser.newPage();
|
|
349
|
+
const errors = [];
|
|
350
|
+
const record = (msg) => {
|
|
351
|
+
errors.push(msg);
|
|
352
|
+
if (opts.echoErrors)
|
|
353
|
+
console.error("[page]", msg);
|
|
354
|
+
};
|
|
355
|
+
page.on("pageerror", (err) => record(err instanceof Error ? err.message : String(err)));
|
|
356
|
+
page.on("console", (msg) => {
|
|
357
|
+
if (msg.type() === "error")
|
|
358
|
+
record(msg.text());
|
|
359
|
+
});
|
|
360
|
+
await page.goto(handle.url, { waitUntil: "load", timeout: 60000 });
|
|
361
|
+
try {
|
|
362
|
+
await page.waitForFunction("window.__stereoframe && window.__stereoframe.ready === true", { timeout: 120000, polling: 100 });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
const state = await page.evaluate(`({ hasProtocol: !!window.__stereoframe, ready: window.__stereoframe?.ready ?? null })`).catch(() => null);
|
|
365
|
+
throw new Error(`runtime never became ready (${JSON.stringify(state)}). ` + `Check that index.html imports assets/stereoframe.js and that all assets load.` + (errors.length ? `
|
|
366
|
+
page errors:
|
|
367
|
+
${errors.join(`
|
|
368
|
+
`)}` : ""));
|
|
369
|
+
}
|
|
370
|
+
const info = await page.evaluate(`({
|
|
371
|
+
duration: window.__stereoframe.duration,
|
|
372
|
+
width: window.__stereoframe.width,
|
|
373
|
+
height: window.__stereoframe.height,
|
|
374
|
+
})`);
|
|
375
|
+
await page.setViewport({ width: info.width, height: info.height, deviceScaleFactor: 1 });
|
|
376
|
+
return {
|
|
377
|
+
page,
|
|
378
|
+
info,
|
|
379
|
+
errors,
|
|
380
|
+
close: async () => {
|
|
381
|
+
await browser.close();
|
|
382
|
+
await handle.close();
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
} catch (err) {
|
|
386
|
+
await browser.close();
|
|
387
|
+
await handle.close();
|
|
388
|
+
throw err;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/render.ts
|
|
393
|
+
async function renderProject(opts) {
|
|
394
|
+
const fps = opts.fps ?? 30;
|
|
395
|
+
const crf = opts.draft ? 28 : opts.crf ?? 18;
|
|
396
|
+
const preset = opts.draft ? "veryfast" : "medium";
|
|
397
|
+
const projectDir = resolve3(opts.projectDir);
|
|
398
|
+
const stamp = new Date().toISOString().replace(/[:T]/g, "-").slice(0, 19);
|
|
399
|
+
const out = resolve3(projectDir, opts.out ?? `renders/render_${stamp}.mp4`);
|
|
400
|
+
mkdirSync2(dirname(out), { recursive: true });
|
|
401
|
+
const session = await openSession(projectDir, { echoErrors: true });
|
|
402
|
+
try {
|
|
403
|
+
const { page, info } = session;
|
|
404
|
+
if (!(info.duration > 0)) {
|
|
405
|
+
throw new Error('composition reported zero duration — set duration="<seconds>" on <sf-scene>');
|
|
406
|
+
}
|
|
407
|
+
const totalFrames = Math.max(1, Math.round(info.duration * fps));
|
|
408
|
+
console.log(`rendering ${totalFrames} frames @ ${fps}fps (${info.width}x${info.height}, ${info.duration}s) → ${out}`);
|
|
409
|
+
const ffmpeg = spawn("ffmpeg", [
|
|
410
|
+
"-y",
|
|
411
|
+
"-loglevel",
|
|
412
|
+
"error",
|
|
413
|
+
"-f",
|
|
414
|
+
"image2pipe",
|
|
415
|
+
"-framerate",
|
|
416
|
+
String(fps),
|
|
417
|
+
"-c:v",
|
|
418
|
+
"png",
|
|
419
|
+
"-i",
|
|
420
|
+
"-",
|
|
421
|
+
"-c:v",
|
|
422
|
+
"libx264",
|
|
423
|
+
"-preset",
|
|
424
|
+
preset,
|
|
425
|
+
"-crf",
|
|
426
|
+
String(crf),
|
|
427
|
+
"-pix_fmt",
|
|
428
|
+
"yuv420p",
|
|
429
|
+
"-movflags",
|
|
430
|
+
"+faststart",
|
|
431
|
+
out
|
|
432
|
+
], { stdio: ["pipe", "inherit", "inherit"] });
|
|
433
|
+
const ffmpegDone = new Promise((resolveDone, rejectDone) => {
|
|
434
|
+
ffmpeg.on("error", (err) => rejectDone(err.message.includes("ENOENT") ? new Error("ffmpeg not found on PATH — install it (e.g. `brew install ffmpeg`)") : err));
|
|
435
|
+
ffmpeg.on("close", (code) => code === 0 ? resolveDone() : rejectDone(new Error(`ffmpeg exited with code ${code}`)));
|
|
436
|
+
});
|
|
437
|
+
const cdp = await page.createCDPSession();
|
|
438
|
+
const started = Date.now();
|
|
439
|
+
for (let frame = 0;frame < totalFrames; frame++) {
|
|
440
|
+
await page.evaluate(`window.__stereoframe.seek(${frame / fps})`);
|
|
441
|
+
const shot = await cdp.send("Page.captureScreenshot", {
|
|
442
|
+
format: "png",
|
|
443
|
+
optimizeForSpeed: true
|
|
444
|
+
});
|
|
445
|
+
const ok = ffmpeg.stdin.write(Buffer.from(shot.data, "base64"));
|
|
446
|
+
if (!ok)
|
|
447
|
+
await new Promise((r) => ffmpeg.stdin.once("drain", r));
|
|
448
|
+
if (frame % Math.max(1, Math.floor(totalFrames / 10)) === 0) {
|
|
449
|
+
const pct = Math.round(frame / totalFrames * 100);
|
|
450
|
+
process.stdout.write(`\r ${pct}% (frame ${frame}/${totalFrames})`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
ffmpeg.stdin.end();
|
|
454
|
+
await ffmpegDone;
|
|
455
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
456
|
+
process.stdout.write(`\r 100% (${totalFrames} frames in ${secs}s)
|
|
457
|
+
`);
|
|
458
|
+
return out;
|
|
459
|
+
} finally {
|
|
460
|
+
await session.close();
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// src/scaffold.ts
|
|
465
|
+
import { copyFileSync as copyFileSync2, existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync } from "node:fs";
|
|
466
|
+
import { createRequire } from "node:module";
|
|
467
|
+
import { dirname as dirname2, join as join3, resolve as resolve4 } from "node:path";
|
|
468
|
+
var TEMPLATE = `<!doctype html>
|
|
469
|
+
<html lang="en">
|
|
470
|
+
<head>
|
|
471
|
+
<meta charset="UTF-8" />
|
|
472
|
+
<style>
|
|
473
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
474
|
+
html, body { width: 1920px; height: 1080px; overflow: hidden; background: #000; }
|
|
475
|
+
body { font-family: ui-sans-serif, system-ui, sans-serif; }
|
|
476
|
+
sf-scene { position: absolute; inset: 0; }
|
|
477
|
+
#title {
|
|
478
|
+
position: absolute; bottom: 100px; width: 100%; text-align: center;
|
|
479
|
+
font-size: 64px; font-weight: 700; color: #f4f4f5; letter-spacing: 0.04em;
|
|
480
|
+
}
|
|
481
|
+
</style>
|
|
482
|
+
</head>
|
|
483
|
+
<body>
|
|
484
|
+
<sf-scene duration="5" width="1920" height="1080" background="#101225">
|
|
485
|
+
<sf-camera fov="38" position="0 0.8 6" look-at="0 0 0"></sf-camera>
|
|
486
|
+
<sf-light preset="soft"></sf-light>
|
|
487
|
+
|
|
488
|
+
<sf-mesh id="hero" geometry="icosahedron" args="1.4 2" color="#7dd3fc"
|
|
489
|
+
metalness="0.4" roughness="0.25"></sf-mesh>
|
|
490
|
+
|
|
491
|
+
<sf-animate target="#hero" verb="bounce-in" start="0.3" duration="0.8"></sf-animate>
|
|
492
|
+
<sf-animate target="#hero" verb="turntable" rpm="10"></sf-animate>
|
|
493
|
+
<sf-animate target="#hero" verb="float" amplitude="0.12" period="3"></sf-animate>
|
|
494
|
+
<sf-animate target="#title" verb="fade-in" start="1.2" duration="0.8" rise="30"></sf-animate>
|
|
495
|
+
</sf-scene>
|
|
496
|
+
|
|
497
|
+
<div id="title" class="clip" data-start="1.2">hello stereoframe</div>
|
|
498
|
+
|
|
499
|
+
<script type="module">
|
|
500
|
+
import "./assets/stereoframe.js";
|
|
501
|
+
</script>
|
|
502
|
+
</body>
|
|
503
|
+
</html>
|
|
504
|
+
`;
|
|
505
|
+
function resolveRuntimeBundle() {
|
|
506
|
+
const require2 = createRequire(import.meta.url);
|
|
507
|
+
return require2.resolve("stereoframe-runtime");
|
|
508
|
+
}
|
|
509
|
+
function scaffoldProject(name, cwd = process.cwd()) {
|
|
510
|
+
const dir = resolve4(cwd, name);
|
|
511
|
+
if (existsSync3(join3(dir, "index.html"))) {
|
|
512
|
+
throw new Error(`refusing to overwrite existing project at ${dir}`);
|
|
513
|
+
}
|
|
514
|
+
mkdirSync3(join3(dir, "assets"), { recursive: true });
|
|
515
|
+
writeFileSync(join3(dir, "index.html"), TEMPLATE);
|
|
516
|
+
writeFileSync(join3(dir, ".gitignore"), `renders/
|
|
517
|
+
`);
|
|
518
|
+
copyFileSync2(resolveRuntimeBundle(), join3(dir, "assets", "stereoframe.js"));
|
|
519
|
+
return dir;
|
|
520
|
+
}
|
|
521
|
+
function updateRuntime(projectDir) {
|
|
522
|
+
const target = join3(resolve4(projectDir), "assets", "stereoframe.js");
|
|
523
|
+
if (!existsSync3(dirname2(target))) {
|
|
524
|
+
throw new Error(`no assets/ directory found in ${projectDir}`);
|
|
525
|
+
}
|
|
526
|
+
copyFileSync2(resolveRuntimeBundle(), target);
|
|
527
|
+
return target;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/validate.ts
|
|
531
|
+
import { resolve as resolve5 } from "node:path";
|
|
532
|
+
async function validateProject(projectDir) {
|
|
533
|
+
const findings = [];
|
|
534
|
+
let session;
|
|
535
|
+
try {
|
|
536
|
+
session = await openSession(resolve5(projectDir));
|
|
537
|
+
} catch (err) {
|
|
538
|
+
return [
|
|
539
|
+
{
|
|
540
|
+
rule: "runtime_not_ready",
|
|
541
|
+
severity: "error",
|
|
542
|
+
message: err instanceof Error ? err.message : String(err)
|
|
543
|
+
}
|
|
544
|
+
];
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const { page, info, errors } = session;
|
|
548
|
+
for (const message of errors) {
|
|
549
|
+
findings.push({ rule: "page_error", severity: "error", message });
|
|
550
|
+
}
|
|
551
|
+
if (!(info.duration > 0)) {
|
|
552
|
+
findings.push({
|
|
553
|
+
rule: "zero_duration",
|
|
554
|
+
severity: "error",
|
|
555
|
+
message: "composition reported zero duration.",
|
|
556
|
+
fixHint: 'Set duration="<seconds>" on <sf-scene>.'
|
|
557
|
+
});
|
|
558
|
+
return findings;
|
|
559
|
+
}
|
|
560
|
+
const shots = await page.evaluate("window.__stereoframe.scenes.map(s => ({ start: s.shot.start, duration: s.shot.duration }))");
|
|
561
|
+
const sampleTimes = new Set;
|
|
562
|
+
for (const shot of shots) {
|
|
563
|
+
const end = Math.min(shot.start + shot.duration, info.duration);
|
|
564
|
+
sampleTimes.add(Math.min(shot.start + 0.05, end));
|
|
565
|
+
sampleTimes.add(shot.start + (end - shot.start) / 2);
|
|
566
|
+
sampleTimes.add(Math.max(shot.start, end - 0.05));
|
|
567
|
+
}
|
|
568
|
+
for (const t of [...sampleTimes].sort((a, b) => a - b)) {
|
|
569
|
+
const all = await page.evaluate(`window.__stereoframe.diagnostics(${t})`);
|
|
570
|
+
for (let i = 0;i < all.length; i++) {
|
|
571
|
+
const d = all[i];
|
|
572
|
+
if (!d.visible)
|
|
573
|
+
continue;
|
|
574
|
+
const where = `scene ${i + 1} at t=${t.toFixed(2)}s`;
|
|
575
|
+
if (d.hasNaN) {
|
|
576
|
+
findings.push({
|
|
577
|
+
rule: "nan_transform",
|
|
578
|
+
severity: "error",
|
|
579
|
+
message: `${where}: NaN in object/camera transforms — a verb parameter is probably malformed.`
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (d.litMeshCount > 0 && d.lightCount === 0 && !d.hasEnvironment) {
|
|
583
|
+
findings.push({
|
|
584
|
+
rule: "unlit_scene",
|
|
585
|
+
severity: "warning",
|
|
586
|
+
message: `${where}: ${d.litMeshCount} standard-material mesh(es) but no lights and no environment — they will render black.`,
|
|
587
|
+
fixHint: 'Add <sf-light preset="studio"> or an environment HDRI.'
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
if (d.frustumCoverage === 0) {
|
|
591
|
+
findings.push({
|
|
592
|
+
rule: "all_offscreen",
|
|
593
|
+
severity: "warning",
|
|
594
|
+
message: `${where}: no object intersects the camera frustum — the frame is empty.`,
|
|
595
|
+
fixHint: "Check camera position/look-at and object positions."
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
if (d.meanLuminance !== null && d.meanLuminance < 0.008 && d.meshCount > 0) {
|
|
599
|
+
findings.push({
|
|
600
|
+
rule: "black_frame",
|
|
601
|
+
severity: "warning",
|
|
602
|
+
message: `${where}: rendered frame is nearly black (mean luminance ${(d.meanLuminance * 255).toFixed(1)}/255).`,
|
|
603
|
+
fixHint: "Likely unlit content, an offscreen subject, or an asset that failed to apply."
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
const mid = info.duration / 2;
|
|
609
|
+
const first = await page.evaluate(`window.__stereoframe.fingerprint(${mid})`);
|
|
610
|
+
await page.evaluate(`window.__stereoframe.seek(0)`);
|
|
611
|
+
const second = await page.evaluate(`window.__stereoframe.fingerprint(${mid})`);
|
|
612
|
+
if (first !== second) {
|
|
613
|
+
findings.push({
|
|
614
|
+
rule: "non_idempotent_seek",
|
|
615
|
+
severity: "error",
|
|
616
|
+
message: `seeking t=${mid.toFixed(2)} twice produced different pixels — some state is not a pure function of seek time.`,
|
|
617
|
+
fixHint: "Look for accumulated state, wall-clock reads, or unseeded randomness in escape-hatch code."
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return findings;
|
|
621
|
+
} finally {
|
|
622
|
+
await session.close();
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// src/cli.ts
|
|
627
|
+
function parseArgs(argv) {
|
|
628
|
+
const positional = [];
|
|
629
|
+
const options = new Map;
|
|
630
|
+
for (let i = 0;i < argv.length; i++) {
|
|
631
|
+
const arg = argv[i];
|
|
632
|
+
if (arg.startsWith("--")) {
|
|
633
|
+
const key = arg.slice(2);
|
|
634
|
+
const next = argv[i + 1];
|
|
635
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
636
|
+
options.set(key, next);
|
|
637
|
+
i++;
|
|
638
|
+
} else {
|
|
639
|
+
options.set(key, true);
|
|
640
|
+
}
|
|
641
|
+
} else {
|
|
642
|
+
positional.push(arg);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return { positional, options };
|
|
646
|
+
}
|
|
647
|
+
var HELP = `stereoframe — declarative, deterministic 3D video on three.js
|
|
648
|
+
|
|
649
|
+
USAGE
|
|
650
|
+
stereoframe init <name> scaffold a new composition project
|
|
651
|
+
stereoframe lint [dir] static checks (markup, assets, time purity)
|
|
652
|
+
stereoframe validate [dir] headless run: errors, lighting, framing, idempotency
|
|
653
|
+
--json machine-readable findings (both lint and validate)
|
|
654
|
+
stereoframe render [dir] render index.html to mp4
|
|
655
|
+
--fps <n> frames per second (default 30)
|
|
656
|
+
--out <path> output file (default renders/render_<timestamp>.mp4)
|
|
657
|
+
--crf <n> x264 quality, lower = better (default 18)
|
|
658
|
+
--draft fast low-quality render for iteration
|
|
659
|
+
stereoframe preview [dir] serve with looping wall-clock playback
|
|
660
|
+
--port <n> fixed port (default: random)
|
|
661
|
+
stereoframe add <block> [dir] install a visual block's assets + print usage
|
|
662
|
+
stereoframe blocks list available blocks
|
|
663
|
+
stereoframe update [dir] refresh assets/stereoframe.js from the CLI's bundled runtime
|
|
664
|
+
`;
|
|
665
|
+
async function main() {
|
|
666
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
667
|
+
const { positional, options } = parseArgs(rest);
|
|
668
|
+
const dir = positional[0] ?? ".";
|
|
669
|
+
switch (command) {
|
|
670
|
+
case "init": {
|
|
671
|
+
const name = positional[0];
|
|
672
|
+
if (!name)
|
|
673
|
+
throw new Error("usage: stereoframe init <name>");
|
|
674
|
+
const created = scaffoldProject(name);
|
|
675
|
+
console.log(`created ${created}`);
|
|
676
|
+
console.log(`next: cd ${name} && stereoframe render`);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
case "lint": {
|
|
680
|
+
const htmlPath = join4(resolve6(dir), "index.html");
|
|
681
|
+
if (!existsSync4(htmlPath))
|
|
682
|
+
throw new Error(`no index.html in ${resolve6(dir)}`);
|
|
683
|
+
const findings = lintHtml(readFileSync(htmlPath, "utf8"), {
|
|
684
|
+
fileExists: (rel) => existsSync4(join4(resolve6(dir), rel))
|
|
685
|
+
});
|
|
686
|
+
reportFindings("lint", findings, options.get("json") === true);
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
case "validate": {
|
|
690
|
+
const findings = await validateProject(dir);
|
|
691
|
+
reportFindings("validate", findings, options.get("json") === true);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
case "render": {
|
|
695
|
+
const out = await renderProject({
|
|
696
|
+
projectDir: dir,
|
|
697
|
+
fps: options.has("fps") ? Number(options.get("fps")) : undefined,
|
|
698
|
+
crf: options.has("crf") ? Number(options.get("crf")) : undefined,
|
|
699
|
+
out: typeof options.get("out") === "string" ? options.get("out") : undefined,
|
|
700
|
+
draft: options.get("draft") === true
|
|
701
|
+
});
|
|
702
|
+
console.log(out);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
case "preview": {
|
|
706
|
+
const port = options.has("port") ? Number(options.get("port")) : 0;
|
|
707
|
+
const handle = await serveProject(dir, port);
|
|
708
|
+
console.log(`preview: ${handle.url}?sf-preview`);
|
|
709
|
+
console.log("press ctrl-c to stop");
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
case "add": {
|
|
713
|
+
const name = positional[0];
|
|
714
|
+
if (!name)
|
|
715
|
+
throw new Error(`usage: stereoframe add <block>
|
|
716
|
+
|
|
717
|
+
available blocks:
|
|
718
|
+
${listBlocks()}`);
|
|
719
|
+
console.log(addBlock(name, positional[1] ?? "."));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
case "blocks": {
|
|
723
|
+
console.log(listBlocks());
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
case "update": {
|
|
727
|
+
console.log(`updated ${updateRuntime(dir)}`);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
case "help":
|
|
731
|
+
case "--help":
|
|
732
|
+
case undefined:
|
|
733
|
+
console.log(HELP);
|
|
734
|
+
return;
|
|
735
|
+
default:
|
|
736
|
+
throw new Error(`unknown command: ${command}
|
|
737
|
+
|
|
738
|
+
${HELP}`);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function reportFindings(command, findings, asJson) {
|
|
742
|
+
const errors = findings.filter((f) => f.severity === "error").length;
|
|
743
|
+
const warnings = findings.length - errors;
|
|
744
|
+
if (asJson) {
|
|
745
|
+
console.log(JSON.stringify({ command, errors, warnings, findings }, null, 2));
|
|
746
|
+
} else {
|
|
747
|
+
for (const f of findings) {
|
|
748
|
+
const mark = f.severity === "error" ? "✗" : "⚠";
|
|
749
|
+
console.log(` ${mark} ${f.rule}: ${f.message}`);
|
|
750
|
+
if (f.fixHint)
|
|
751
|
+
console.log(` Fix: ${f.fixHint}`);
|
|
752
|
+
}
|
|
753
|
+
console.log(`${command}: ${errors} error(s), ${warnings} warning(s)`);
|
|
754
|
+
}
|
|
755
|
+
if (errors > 0)
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}
|
|
758
|
+
main().catch((err) => {
|
|
759
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
760
|
+
process.exit(1);
|
|
761
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stereoframe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Declarative, deterministic 3D video on three.js — scaffold, lint, validate, and render compositions built for AI agents.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/kiyeonjeon21/stereoframe",
|
|
9
|
+
"directory": "packages/cli"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"stereoframe": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "assets"],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"prepublishOnly": "bun run build",
|
|
21
|
+
"build": "bun build src/cli.ts --outfile dist/cli.js --format esm --target node --external puppeteer --external stereoframe-runtime/vocab --banner '#!/usr/bin/env node' && chmod +x dist/cli.js",
|
|
22
|
+
"test": "bun test"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"stereoframe-runtime": "0.1.0",
|
|
26
|
+
"puppeteer": "^24.0.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/bun": "^1.3.14",
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"typescript": "^5.6.0"
|
|
32
|
+
}
|
|
33
|
+
}
|