@glissade/cli 0.1.0 → 0.3.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/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @glissade/cli
2
+
3
+ `gs` — headless rendering and the dev/capture loop. `gs render` evaluates every frame, rasterizes on Skia (no browser anywhere), writes a PNG sequence or muxes mp4/webm via FFmpeg with sample-accurate audio. v2: `gs dev --record` serves a scene with its state machines mounted and writes input-trace sidecars; `gs render --trace/--state` are the deterministic export routes for interactive scenes.
4
+
5
+ ```sh
6
+ npm i -D @glissade/cli
7
+ ```
8
+
9
+ ```sh
10
+ gs render scene.ts --out out.mp4 --fps 60
11
+ gs dev scene.ts --record # capture a take
12
+ gs render scene.ts --trace scene.button.take1.trace.json --out take.mp4
13
+ ```
14
+
15
+ ## Part of glissade
16
+
17
+ *(glide & slide)* — programmatic motion graphics for TypeScript: realtime-first in any web page, deterministic headless video export from the same code, a visual studio over the same document. No generator functions.
18
+
19
+ - [Repository & full README](https://github.com/tyevco/glissade)
20
+ - [Getting started](https://github.com/tyevco/glissade/blob/main/docs/getting-started.md) · [Concepts](https://github.com/tyevco/glissade/blob/main/docs/concepts.md) · [Interactivity](https://github.com/tyevco/glissade/blob/main/docs/interactivity.md)
21
+
22
+ Apache-2.0.
package/dist/cli.js CHANGED
@@ -17,7 +17,13 @@ function parseArgs(argv) {
17
17
  if (a.startsWith("--")) {
18
18
  const eq = a.indexOf("=");
19
19
  if (eq >= 0) flags.set(a.slice(2, eq), a.slice(eq + 1));
20
- else flags.set(a.slice(2), argv[++i] ?? "");
20
+ else {
21
+ const next = argv[i + 1];
22
+ if (next !== void 0 && !next.startsWith("--")) {
23
+ flags.set(a.slice(2), next);
24
+ i++;
25
+ } else flags.set(a.slice(2), "");
26
+ }
21
27
  } else positional.push(a);
22
28
  }
23
29
  return {
@@ -27,21 +33,40 @@ function parseArgs(argv) {
27
33
  }
28
34
  const USAGE = `usage:
29
35
  gs render <scene-module> [options]
36
+ gs dev <scene-module> [--record] [--port <n>]
30
37
 
31
- options:
38
+ render options:
32
39
  --out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
33
40
  --fps <n> frames per second (default: timeline fps, else 60)
34
41
  --range <a..b> seconds to render (default: 0..duration)
42
+ --trace <file> replay an InputTrace and bake it (machine scenes, §A.6)
43
+ --state <name> render one machine state's timeline linearly
44
+ --force downgrade a trace hash mismatch to a warning
45
+
46
+ dev options:
47
+ --record add a Record button; writes .trace.json sidecars next to the module
48
+ --port <n> listen port (default: any free port)
35
49
  `;
36
50
  async function main() {
37
51
  const [command, ...rest] = process.argv.slice(2);
38
- if (command !== "render") {
52
+ if (command !== "render" && command !== "dev") {
39
53
  console.error(USAGE);
40
54
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
41
55
  }
42
56
  const { positional, flags } = parseArgs(rest);
43
57
  const modulePath = positional[0];
44
58
  if (!modulePath) fail(`missing <scene-module>\n${USAGE}`);
59
+ if (command === "dev") {
60
+ const { dev } = await import("./dev.js").then((n) => n.n);
61
+ const portFlag = flags.get("port");
62
+ const server = await dev({
63
+ modulePath,
64
+ ...portFlag ? { port: parseInt(portFlag, 10) } : {},
65
+ record: flags.has("record")
66
+ });
67
+ process.stderr.write(`gs dev: http://localhost:${server.port}/${flags.has("record") ? " (recording UI on)" : ""}\n`);
68
+ return;
69
+ }
45
70
  let range;
46
71
  const rangeFlag = flags.get("range");
47
72
  if (rangeFlag) {
@@ -57,6 +82,9 @@ async function main() {
57
82
  out: flags.get("out") ?? "out",
58
83
  ...fpsFlag ? { fps: parseInt(fpsFlag, 10) } : {},
59
84
  ...range ? { range } : {},
85
+ ...flags.has("trace") ? { trace: flags.get("trace") } : {},
86
+ ...flags.has("state") ? { state: flags.get("state") } : {},
87
+ ...flags.has("force") ? { force: true } : {},
60
88
  onProgress: (n, total) => {
61
89
  if (n % 30 === 0 || n === total) process.stderr.write(`\rrendering ${n}/${total} frames`);
62
90
  }
package/dist/dev.js ADDED
@@ -0,0 +1,135 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { existsSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
4
+ import { createServer } from "node:http";
5
+ //#region src/dev.ts
6
+ /**
7
+ * gs dev (v2 §C.5): serve the scene with its machines mounted; --record adds
8
+ * a Record button that writes .trace.json sidecars next to the module on
9
+ * stop. This is the walkable capture path — producing a trace must never
10
+ * require a hand-written harness page.
11
+ */
12
+ var dev_exports = /* @__PURE__ */ __exportAll({ dev: () => dev });
13
+ /** The in-browser harness, bundled fresh per page load (F5 picks up edits). */
14
+ function harnessSource(absModulePath, record) {
15
+ return `
16
+ import mod from ${JSON.stringify(absModulePath)};
17
+ import { mount } from '@glissade/player';
18
+ import { createMachine, recordTrace } from '@glissade/interact';
19
+
20
+ const scene = mod.createScene();
21
+ const canvas = document.getElementById('stage');
22
+ canvas.width = scene.size.w;
23
+ canvas.height = scene.size.h;
24
+ const mounted = mount(scene, mod.timeline, canvas, { loop: true, autoplay: true });
25
+
26
+ const machines = [];
27
+ for (const spec of mod.machines ?? []) {
28
+ const machine = createMachine(spec.doc, {
29
+ resolve: (t) => scene.resolveTarget(t),
30
+ ...(spec.timelines ? { timelines: spec.timelines } : {}),
31
+ });
32
+ mounted.player.attach(machine);
33
+ machine.clock.subscribe(() => mounted.render()); // machine steps repaint even while paused
34
+ if (spec.wire) spec.wire({ scene, machine, element: canvas });
35
+ machines.push(machine);
36
+ }
37
+ ${record ? `
38
+ const btn = document.getElementById('rec');
39
+ const status = document.getElementById('status');
40
+ btn.style.display = 'inline-block';
41
+ let recs = null;
42
+ btn.onclick = async () => {
43
+ if (!machines.length) { status.textContent = 'no machines declared by this module'; return; }
44
+ if (!recs) {
45
+ recs = machines.map((m) => ({ id: m.id, rec: recordTrace(m) }));
46
+ btn.textContent = 'Stop & save';
47
+ status.textContent = 'recording…';
48
+ return;
49
+ }
50
+ const takes = recs.map(({ id, rec }) => ({ id, trace: rec.stop() }));
51
+ recs = null;
52
+ btn.textContent = 'Record';
53
+ const res = await fetch('/__trace', { method: 'POST', body: JSON.stringify(takes) });
54
+ status.textContent = 'saved ' + (await res.json()).saved.join(', ');
55
+ };` : ""}
56
+ `;
57
+ }
58
+ const PAGE = `<!doctype html>
59
+ <meta charset="utf-8">
60
+ <title>gs dev</title>
61
+ <style>
62
+ body { margin: 0; background: #16161a; color: #eee; font: 13px system-ui; display: grid; place-items: center; min-height: 100vh; }
63
+ canvas { box-shadow: 0 4px 24px #000a; background: #fff; }
64
+ #ui { position: fixed; top: 12px; right: 12px; display: flex; gap: 8px; align-items: center; }
65
+ #rec { display: none; background: #e0245e; color: #fff; border: 0; border-radius: 6px; padding: 6px 14px; cursor: pointer; }
66
+ </style>
67
+ <div id="ui"><span id="status"></span><button id="rec">Record</button></div>
68
+ <canvas id="stage"></canvas>
69
+ <script type="module" src="/bundle.js"><\/script>
70
+ `;
71
+ function nextTakePath(moduleAbs, machineId) {
72
+ const dir = dirname(moduleAbs);
73
+ const base = basename(moduleAbs, extname(moduleAbs));
74
+ for (let n = 1;; n++) {
75
+ const p = join(dir, `${base}.${machineId}.take${n}.trace.json`);
76
+ if (!existsSync(p)) return p;
77
+ }
78
+ }
79
+ async function dev(opts) {
80
+ const abs = isAbsolute(opts.modulePath) ? opts.modulePath : resolve(process.cwd(), opts.modulePath);
81
+ const { build } = await import("esbuild");
82
+ const bundle = async () => {
83
+ return (await build({
84
+ stdin: {
85
+ contents: harnessSource(abs, opts.record ?? false),
86
+ resolveDir: dirname(abs),
87
+ sourcefile: "gs-dev-harness.ts",
88
+ loader: "ts"
89
+ },
90
+ bundle: true,
91
+ format: "esm",
92
+ write: false,
93
+ sourcemap: "inline",
94
+ logLevel: "silent"
95
+ })).outputFiles[0].text;
96
+ };
97
+ const server = createServer((req, res) => {
98
+ (async () => {
99
+ try {
100
+ if (req.method === "POST" && req.url === "/__trace") {
101
+ const chunks = [];
102
+ for await (const c of req) chunks.push(c);
103
+ const takes = JSON.parse(Buffer.concat(chunks).toString("utf8"));
104
+ const saved = [];
105
+ for (const take of takes) {
106
+ const path = nextTakePath(abs, take.id);
107
+ writeFileSync(path, JSON.stringify(take.trace, null, 2));
108
+ saved.push(basename(path));
109
+ process.stderr.write(`gs dev: wrote ${path}\n`);
110
+ }
111
+ res.writeHead(200, { "content-type": "application/json" });
112
+ res.end(JSON.stringify({ saved }));
113
+ } else if (req.url === "/bundle.js") {
114
+ const js = await bundle();
115
+ res.writeHead(200, { "content-type": "text/javascript" });
116
+ res.end(js);
117
+ } else {
118
+ res.writeHead(200, { "content-type": "text/html" });
119
+ res.end(PAGE);
120
+ }
121
+ } catch (err) {
122
+ res.writeHead(500, { "content-type": "text/plain" });
123
+ res.end(err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err));
124
+ }
125
+ })();
126
+ });
127
+ await new Promise((resolveListen) => server.listen(opts.port ?? 0, () => resolveListen()));
128
+ const addr = server.address();
129
+ return {
130
+ port: typeof addr === "object" && addr ? addr.port : opts.port ?? 0,
131
+ close: () => new Promise((r) => server.close(() => r()))
132
+ };
133
+ }
134
+ //#endregion
135
+ export { dev_exports as n, dev as t };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { SceneModule, VideoFrameSource } from "@glissade/scene";
1
+ import { Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
2
2
  import { Image } from "@napi-rs/canvas";
3
- import { AudioClip, Key } from "@glissade/core";
3
+ import { AudioClip, Key, Timeline } from "@glissade/core";
4
4
 
5
5
  //#region src/render.d.ts
6
6
 
@@ -10,6 +10,12 @@ interface RenderOptions {
10
10
  fps?: number;
11
11
  /** seconds; defaults to [0, duration] */
12
12
  range?: [number, number];
13
+ /** InputTrace file: replay → bake → render (v2 §A.6 route 2). */
14
+ trace?: string;
15
+ /** Render one machine state's timeline linearly (route 3). */
16
+ state?: string;
17
+ /** Downgrade a trace hash mismatch to a warning. */
18
+ force?: boolean;
13
19
  onProgress?: (frame: number, total: number) => void;
14
20
  }
15
21
  declare class SceneModuleError extends Error {
@@ -99,4 +105,38 @@ interface EncoderChoice {
99
105
  }
100
106
  declare function pickEncoder(kind: 'video' | 'audio', container: 'mp4' | 'webm', encoders?: Set<string>): EncoderChoice;
101
107
  //#endregion
102
- export { AudioMixError, type AudioMixPlan, type EncoderChoice, FfmpegVideoFrameSource, NoEncoderError, type RenderOptions, SceneModuleError, type VideoInfo, VideoProbeError, atempoChain, availableEncoders, ffmpegAvailable, gainExpression, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath };
108
+ //#region src/machines.d.ts
109
+ interface MachineRenderFlags {
110
+ trace?: string;
111
+ state?: string;
112
+ force?: boolean;
113
+ }
114
+ declare class MachineExportError extends Error {
115
+ constructor(message: string);
116
+ }
117
+ /**
118
+ * Decide what document to render. No machines: the module timeline, untouched.
119
+ * With machines: --state merges one state's timeline over the ambient document;
120
+ * --trace replays and bakes; neither is a hard error listing the routes.
121
+ */
122
+ declare function resolveRenderDoc(mod: SceneModule, scene: Scene, flags: MachineRenderFlags): Timeline;
123
+ //#endregion
124
+ //#region src/dev.d.ts
125
+ /**
126
+ * gs dev (v2 §C.5): serve the scene with its machines mounted; --record adds
127
+ * a Record button that writes .trace.json sidecars next to the module on
128
+ * stop. This is the walkable capture path — producing a trace must never
129
+ * require a hand-written harness page.
130
+ */
131
+ interface DevOptions {
132
+ modulePath: string;
133
+ port?: number;
134
+ record?: boolean;
135
+ }
136
+ interface DevServer {
137
+ port: number;
138
+ close(): Promise<void>;
139
+ }
140
+ declare function dev(opts: DevOptions): Promise<DevServer>;
141
+ //#endregion
142
+ export { AudioMixError, type AudioMixPlan, type DevOptions, type DevServer, type EncoderChoice, FfmpegVideoFrameSource, MachineExportError, type MachineRenderFlags, NoEncoderError, type RenderOptions, SceneModuleError, type VideoInfo, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath, resolveRenderDoc };
package/dist/index.js CHANGED
@@ -2,4 +2,6 @@ import { i as render, n as ffmpegAvailable, r as loadSceneModule, t as SceneModu
2
2
  import { n as VideoProbeError, r as probeVideo, t as FfmpegVideoFrameSource } from "./videoSource.js";
3
3
  import { a as planAudioMix, i as gainExpression, n as atempoChain, o as resolveAssetPath, t as AudioMixError } from "./audioMix.js";
4
4
  import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
5
- export { AudioMixError, FfmpegVideoFrameSource, NoEncoderError, SceneModuleError, VideoProbeError, atempoChain, availableEncoders, ffmpegAvailable, gainExpression, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath };
5
+ import { r as resolveRenderDoc, t as MachineExportError } from "./machines.js";
6
+ import { t as dev } from "./dev.js";
7
+ export { AudioMixError, FfmpegVideoFrameSource, MachineExportError, NoEncoderError, SceneModuleError, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath, resolveRenderDoc };
@@ -0,0 +1,108 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { readFileSync } from "node:fs";
3
+ import "@glissade/scene";
4
+ import { compileTimeline, timeline } from "@glissade/core";
5
+ import { bakeTrace, createMachine } from "@glissade/interact";
6
+ //#region src/machines.ts
7
+ /**
8
+ * Machine export routes (v2 §A.6): a scene module that declares machines
9
+ * renders only through --trace (record → replay → bake) or --state — anything
10
+ * else is a build error, never a silent freeze-frame. The result is always a
11
+ * plain linear Timeline the rest of the §5 pipeline consumes unchanged.
12
+ */
13
+ var machines_exports = /* @__PURE__ */ __exportAll({
14
+ MachineExportError: () => MachineExportError,
15
+ resolveRenderDoc: () => resolveRenderDoc
16
+ });
17
+ var MachineExportError = class extends Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = "MachineExportError";
21
+ }
22
+ };
23
+ function specTargets(spec) {
24
+ const targets = /* @__PURE__ */ new Set();
25
+ for (const [id, s] of Object.entries(spec.doc.states)) {
26
+ const tl = "ref" in s.timeline ? spec.timelines?.[s.timeline.ref] : s.timeline;
27
+ if (!tl) throw new MachineExportError(`machine '${spec.doc.id}': state '${id}' references a timeline not in spec.timelines`);
28
+ for (const t of compileTimeline(tl).tracks.keys()) targets.add(t);
29
+ }
30
+ return targets;
31
+ }
32
+ /** §A.1: ambient timeline and every machine must own statically disjoint target sets. */
33
+ function checkDisjoint(ambientTargets, specs) {
34
+ const owner = /* @__PURE__ */ new Map();
35
+ for (const t of ambientTargets) owner.set(t, "the scene timeline");
36
+ for (const spec of specs) for (const t of specTargets(spec)) {
37
+ const prev = owner.get(t);
38
+ if (prev) throw new MachineExportError(`target '${t}' is animated by both ${prev} and machine '${spec.doc.id}' — concurrent writers are the silent last-writer-wins §2.2 exists to kill (§A.1)`);
39
+ owner.set(t, `machine '${spec.doc.id}'`);
40
+ }
41
+ }
42
+ /**
43
+ * Decide what document to render. No machines: the module timeline, untouched.
44
+ * With machines: --state merges one state's timeline over the ambient document;
45
+ * --trace replays and bakes; neither is a hard error listing the routes.
46
+ */
47
+ function resolveRenderDoc(mod, scene, flags) {
48
+ const specs = mod.machines ?? [];
49
+ if (specs.length === 0) {
50
+ if (flags.trace || flags.state) throw new MachineExportError("this scene module declares no machines; --trace/--state do not apply");
51
+ return mod.timeline;
52
+ }
53
+ const ambient = compileTimeline(mod.timeline);
54
+ checkDisjoint(new Set(ambient.tracks.keys()), specs);
55
+ if (flags.state !== void 0) {
56
+ if (specs.length !== 1) throw new MachineExportError(`--state needs exactly one machine; this module declares ${specs.length}`);
57
+ const spec = specs[0];
58
+ const st = spec.doc.states[flags.state];
59
+ if (!st) throw new MachineExportError(`--state '${flags.state}': machine '${spec.doc.id}' has no such state (have: ${Object.keys(spec.doc.states).join(", ")})`);
60
+ const stTl = "ref" in st.timeline ? spec.timelines?.[st.timeline.ref] : st.timeline;
61
+ if (!stTl) throw new MachineExportError(`state '${flags.state}' references a timeline not in spec.timelines`);
62
+ return timeline({
63
+ duration: compileTimeline(stTl).duration,
64
+ ...mod.timeline.fps !== void 0 ? { fps: mod.timeline.fps } : {},
65
+ ...mod.timeline.assets !== void 0 ? { assets: mod.timeline.assets } : {},
66
+ children: [{
67
+ timeline: mod.timeline,
68
+ at: 0,
69
+ mode: "add"
70
+ }, {
71
+ timeline: stTl,
72
+ at: 0,
73
+ mode: "add"
74
+ }]
75
+ });
76
+ }
77
+ if (flags.trace !== void 0) {
78
+ const trace = JSON.parse(readFileSync(flags.trace, "utf8"));
79
+ const machines = specs.map((spec) => createMachine(spec.doc, {
80
+ resolve: (t) => scene.resolveTarget(t),
81
+ ...spec.timelines ? { timelines: spec.timelines } : {}
82
+ }));
83
+ let machine = machines.find((m) => m.hash === trace.machineHash);
84
+ if (!machine) if (flags.force && machines.length === 1) machine = machines[0];
85
+ else {
86
+ for (const m of machines) m.dispose();
87
+ throw new MachineExportError(`trace ${trace.machineHash} matches none of this module's machines — re-record, or pass --force with a single-machine module (§C.5)`);
88
+ }
89
+ const baked = bakeTrace(machine, trace, { ...flags.force ? { force: true } : {} });
90
+ for (const m of machines) m.dispose();
91
+ return timeline({
92
+ ...mod.timeline.fps !== void 0 ? { fps: mod.timeline.fps } : {},
93
+ ...mod.timeline.assets !== void 0 ? { assets: mod.timeline.assets } : {},
94
+ children: [{
95
+ timeline: mod.timeline,
96
+ at: 0,
97
+ mode: "add"
98
+ }, {
99
+ timeline: baked,
100
+ at: 0,
101
+ mode: "add"
102
+ }]
103
+ });
104
+ }
105
+ throw new MachineExportError(`scene declares ${specs.length} state machine(s): every machine needs an export story (§A.6).\n --trace <take.trace.json> record → replay → bake (capture with 'gs dev --record')\n --state <name> render one state's timeline linearly`);
106
+ }
107
+ //#endregion
108
+ export { machines_exports as n, resolveRenderDoc as r, MachineExportError as t };
package/dist/render.js CHANGED
@@ -30,7 +30,12 @@ function ffmpegAvailable() {
30
30
  async function render(opts) {
31
31
  const mod = await loadSceneModule(opts.modulePath);
32
32
  const scene = mod.createScene();
33
- const doc = mod.timeline;
33
+ const { resolveRenderDoc } = await import("./machines.js").then((n) => n.n);
34
+ const doc = resolveRenderDoc(mod, scene, {
35
+ ...opts.trace !== void 0 ? { trace: opts.trace } : {},
36
+ ...opts.state !== void 0 ? { state: opts.state } : {},
37
+ ...opts.force !== void 0 ? { force: opts.force } : {}
38
+ });
34
39
  const fps = opts.fps ?? doc.fps ?? 60;
35
40
  const { compileTimeline } = await import("@glissade/core");
36
41
  const compiled = compileTimeline(doc);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -18,10 +18,13 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@napi-rs/canvas": "^0.1.65",
21
+ "esbuild": "^0.28.0",
21
22
  "jiti": "^2.4.2",
22
- "@glissade/backend-skia": "0.1.0",
23
- "@glissade/core": "0.1.0",
24
- "@glissade/scene": "0.1.0"
23
+ "@glissade/backend-skia": "0.3.0",
24
+ "@glissade/core": "0.3.0",
25
+ "@glissade/interact": "0.3.0",
26
+ "@glissade/player": "0.3.0",
27
+ "@glissade/scene": "0.3.0"
25
28
  },
26
29
  "repository": {
27
30
  "type": "git",