@glissade/cli 0.2.0 → 0.4.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.
@@ -0,0 +1,71 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { compileTimeline, key, timeline, track } from "@glissade/core";
5
+ import { toSrt, toVtt } from "@glissade/narrate";
6
+ //#region src/captions.ts
7
+ /**
8
+ * Render-time caption handling (--captions burn|sidecar|off). Captions are
9
+ * ordinary document data (a string track + a Text node), so hiding them is a
10
+ * DOCUMENT operation: an override track on 'captions/opacity' merged over
11
+ * the scene doc — never a scene-graph mutation.
12
+ */
13
+ var captions_exports = /* @__PURE__ */ __exportAll({
14
+ hideCaptionsDoc: () => hideCaptionsDoc,
15
+ parseCaptionsMode: () => parseCaptionsMode,
16
+ timingPathFor: () => timingPathFor,
17
+ writeCaptionSidecars: () => writeCaptionSidecars
18
+ });
19
+ function parseCaptionsMode(raw) {
20
+ if (raw === void 0 || raw === "" || raw === "burn") return "burn";
21
+ if (raw === "sidecar" || raw === "off") return raw;
22
+ throw new Error(`--captions must be burn, sidecar, or off (got '${raw}')`);
23
+ }
24
+ /** `<module>.narration.timing.json`, when the scene has been narrated. */
25
+ function timingPathFor(modulePath) {
26
+ const candidate = modulePath.replace(/\.[jt]sx?$/, "") + ".narration.timing.json";
27
+ return existsSync(candidate) ? candidate : null;
28
+ }
29
+ /**
30
+ * Hide the caption node for sidecar/off renders. The override spans the whole
31
+ * duration: coalescing is last-insertion-wins only inside the later track's
32
+ * key range (§2.2), so a point key would lose to an authored opacity track.
33
+ */
34
+ function hideCaptionsDoc(doc, captionsId = "captions") {
35
+ const duration = compileTimeline(doc).duration;
36
+ const hide = track(`${captionsId}/opacity`, "number", [key(0, 0, { interp: "hold" }), key(Math.max(duration, 1e-6), 0)]);
37
+ return timeline({
38
+ duration,
39
+ ...doc.fps !== void 0 ? { fps: doc.fps } : {},
40
+ ...doc.assets !== void 0 ? { assets: doc.assets } : {},
41
+ children: [{
42
+ timeline: doc,
43
+ at: 0,
44
+ mode: "add"
45
+ }, {
46
+ timeline: {
47
+ version: 1,
48
+ tracks: [hide]
49
+ },
50
+ at: 0,
51
+ mode: "add"
52
+ }]
53
+ });
54
+ }
55
+ /** Write .srt + .vtt next to the render output; returns the paths. */
56
+ function writeCaptionSidecars(timingPath, outPath) {
57
+ const timing = JSON.parse(readFileSync(timingPath, "utf8"));
58
+ const isFile = /\.(mp4|webm)$/i.test(outPath);
59
+ const dir = isFile ? dirname(outPath) : outPath;
60
+ const stem = isFile ? basename(outPath).replace(/\.(mp4|webm)$/i, "") : "captions";
61
+ const srt = join(dir, `${stem}.srt`);
62
+ const vtt = join(dir, `${stem}.vtt`);
63
+ writeFileSync(srt, toSrt(timing));
64
+ writeFileSync(vtt, toVtt(timing));
65
+ return {
66
+ srt,
67
+ vtt
68
+ };
69
+ }
70
+ //#endregion
71
+ export { parseCaptionsMode as n, captions_exports as t };
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { i as render } from "./render.js";
3
+ import { n as parseCaptionsMode } from "./captions.js";
3
4
  //#region src/cli.ts
4
5
  /**
5
6
  * gs — the glissade CLI (DESIGN.md §5.7).
@@ -9,6 +10,13 @@ function fail(msg) {
9
10
  console.error(`gs: ${msg}`);
10
11
  process.exit(1);
11
12
  }
13
+ function parseCaptionsModeOrFail(raw) {
14
+ try {
15
+ return parseCaptionsMode(raw);
16
+ } catch (err) {
17
+ fail(err instanceof Error ? err.message : String(err));
18
+ }
19
+ }
12
20
  function parseArgs(argv) {
13
21
  const positional = [];
14
22
  const flags = /* @__PURE__ */ new Map();
@@ -34,6 +42,8 @@ function parseArgs(argv) {
34
42
  const USAGE = `usage:
35
43
  gs render <scene-module> [options]
36
44
  gs dev <scene-module> [--record] [--port <n>]
45
+ gs import <lottie.json> [--out <dir>] [--allow-degraded]
46
+ gs narrate <scene-module|script.narration.json> [--provider <id>] [--force]
37
47
 
38
48
  render options:
39
49
  --out <path> output directory for a PNG sequence, or .mp4/.webm (needs ffmpeg). default: ./out
@@ -42,20 +52,59 @@ render options:
42
52
  --trace <file> replay an InputTrace and bake it (machine scenes, §A.6)
43
53
  --state <name> render one machine state's timeline linearly
44
54
  --force downgrade a trace hash mismatch to a warning
55
+ --captions <m> burn (default) | sidecar | off; burn/sidecar also write .srt/.vtt
45
56
 
46
57
  dev options:
47
58
  --record add a Record button; writes .trace.json sidecars next to the module
48
59
  --port <n> listen port (default: any free port)
60
+
61
+ import options:
62
+ --out <dir> output directory for the generated scene module (default: .)
63
+ --allow-degraded downgrade degradable rejections (expressions, merge-paths modes != 1) to warnings
64
+
65
+ narrate options (the explicit TTS prepare step; render itself stays offline):
66
+ --provider <id> fake | espeak | openai (default: the script's provider, else espeak)
67
+ --force ignore the cache and re-synthesize every segment
49
68
  `;
50
69
  async function main() {
51
70
  const [command, ...rest] = process.argv.slice(2);
52
- if (command !== "render" && command !== "dev") {
71
+ if (command !== "render" && command !== "dev" && command !== "import" && command !== "narrate") {
53
72
  console.error(USAGE);
54
73
  process.exit(command === void 0 || command === "help" || command === "--help" ? 0 : 1);
55
74
  }
56
75
  const { positional, flags } = parseArgs(rest);
57
76
  const modulePath = positional[0];
58
- if (!modulePath) fail(`missing <scene-module>\n${USAGE}`);
77
+ if (!modulePath) fail(`missing ${command === "import" ? "<lottie.json>" : "<scene-module>"}\n${USAGE}`);
78
+ if (command === "narrate") {
79
+ const { narrateCommand } = await import("./narrate.js");
80
+ try {
81
+ const result = await narrateCommand({
82
+ input: modulePath,
83
+ ...flags.has("provider") ? { provider: flags.get("provider") } : {},
84
+ ...flags.has("force") ? { force: true } : {}
85
+ });
86
+ const parts = [result.synthesized.length > 0 ? `synthesized ${result.synthesized.join(", ")}` : null, result.reused.length > 0 ? `reused ${result.reused.length} cached` : null].filter(Boolean);
87
+ process.stderr.write(`gs narrate: ${parts.join("; ") || "nothing to do"} → ${result.timingPath}\n`);
88
+ } catch (err) {
89
+ fail(err instanceof Error ? err.message : String(err));
90
+ }
91
+ return;
92
+ }
93
+ if (command === "import") {
94
+ const { importCommand } = await import("./import.js").then((n) => n.n);
95
+ try {
96
+ const result = await importCommand({
97
+ input: modulePath,
98
+ out: flags.get("out") ?? ".",
99
+ allowDegraded: flags.has("allow-degraded")
100
+ });
101
+ for (const w of result.warnings) process.stderr.write(`gs import: warning: ${w}\n`);
102
+ process.stderr.write(`gs import: wrote ${result.out}\n`);
103
+ } catch (err) {
104
+ fail(err instanceof Error ? err.message : String(err));
105
+ }
106
+ return;
107
+ }
59
108
  if (command === "dev") {
60
109
  const { dev } = await import("./dev.js").then((n) => n.n);
61
110
  const portFlag = flags.get("port");
@@ -85,6 +134,7 @@ async function main() {
85
134
  ...flags.has("trace") ? { trace: flags.get("trace") } : {},
86
135
  ...flags.has("state") ? { state: flags.get("state") } : {},
87
136
  ...flags.has("force") ? { force: true } : {},
137
+ captions: parseCaptionsModeOrFail(flags.get("captions")),
88
138
  onProgress: (n, total) => {
89
139
  if (n % 30 === 0 || n === total) process.stderr.write(`\rrendering ${n}/${total} frames`);
90
140
  }
package/dist/import.js ADDED
@@ -0,0 +1,31 @@
1
+ import { t as __exportAll } from "./rolldown-runtime.js";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, join, resolve } from "node:path";
4
+ import { generateSceneModule, importLottie } from "@glissade/lottie";
5
+ //#region src/import.ts
6
+ /**
7
+ * gs import (lottie-import.md §3.1): Lottie/bodymovin .json → a generated
8
+ * TypeScript scene module (nodes + inline Timeline) consumable by gs render.
9
+ */
10
+ var import_exports = /* @__PURE__ */ __exportAll({ importCommand: () => importCommand });
11
+ async function importCommand(opts) {
12
+ const inputAbs = resolve(opts.input);
13
+ let json;
14
+ try {
15
+ json = JSON.parse(readFileSync(inputAbs, "utf8"));
16
+ } catch (err) {
17
+ throw new Error(`${opts.input}: ${err instanceof Error ? err.message : String(err)}`);
18
+ }
19
+ const result = importLottie(json, { allowDegraded: opts.allowDegraded === true });
20
+ const code = generateSceneModule(result, { source: basename(inputAbs) });
21
+ const outDir = resolve(opts.out);
22
+ mkdirSync(outDir, { recursive: true });
23
+ const outFile = join(outDir, `${basename(inputAbs).replace(/\.json$/i, "")}.ts`);
24
+ writeFileSync(outFile, code);
25
+ return {
26
+ out: outFile,
27
+ warnings: result.warnings
28
+ };
29
+ }
30
+ //#endregion
31
+ export { import_exports as n, importCommand as t };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Scene, SceneModule, VideoFrameSource } from "@glissade/scene";
2
- import { Image } from "@napi-rs/canvas";
3
2
  import { AudioClip, Key, Timeline } from "@glissade/core";
3
+ import { Image } from "@napi-rs/canvas";
4
4
 
5
5
  //#region src/render.d.ts
6
6
 
@@ -16,6 +16,8 @@ interface RenderOptions {
16
16
  state?: string;
17
17
  /** Downgrade a trace hash mismatch to a warning. */
18
18
  force?: boolean;
19
+ /** burn (default): captions render in-frame; sidecar/off hide the caption node. */
20
+ captions?: 'burn' | 'sidecar' | 'off';
19
21
  onProgress?: (frame: number, total: number) => void;
20
22
  }
21
23
  declare class SceneModuleError extends Error {
@@ -139,4 +141,21 @@ interface DevServer {
139
141
  }
140
142
  declare function dev(opts: DevOptions): Promise<DevServer>;
141
143
  //#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 };
144
+ //#region src/import.d.ts
145
+ /**
146
+ * gs import (lottie-import.md §3.1): Lottie/bodymovin .json → a generated
147
+ * TypeScript scene module (nodes + inline Timeline) consumable by gs render.
148
+ */
149
+ interface ImportOptions {
150
+ input: string;
151
+ /** Output directory; the module is written as <basename>.ts inside it. */
152
+ out: string;
153
+ allowDegraded?: boolean;
154
+ }
155
+ interface ImportCommandResult {
156
+ out: string;
157
+ warnings: string[];
158
+ }
159
+ declare function importCommand(opts: ImportOptions): Promise<ImportCommandResult>;
160
+ //#endregion
161
+ export { AudioMixError, type AudioMixPlan, type DevOptions, type DevServer, type EncoderChoice, FfmpegVideoFrameSource, type ImportCommandResult, type ImportOptions, MachineExportError, type MachineRenderFlags, NoEncoderError, type RenderOptions, SceneModuleError, type VideoInfo, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath, resolveRenderDoc };
package/dist/index.js CHANGED
@@ -4,4 +4,5 @@ import { a as planAudioMix, i as gainExpression, n as atempoChain, o as resolveA
4
4
  import { a as pickEncoder, i as parseEncoderList, n as availableEncoders, t as NoEncoderError } from "./encoders.js";
5
5
  import { r as resolveRenderDoc, t as MachineExportError } from "./machines.js";
6
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 };
7
+ import { t as importCommand } from "./import.js";
8
+ export { AudioMixError, FfmpegVideoFrameSource, MachineExportError, NoEncoderError, SceneModuleError, VideoProbeError, atempoChain, availableEncoders, dev, ffmpegAvailable, gainExpression, importCommand, loadSceneModule, parseEncoderList, pickEncoder, planAudioMix, probeVideo, render, resolveAssetPath, resolveRenderDoc };
@@ -0,0 +1,20 @@
1
+ import { scriptPathFor, synthesizeScript } from "@glissade/narrate/providers";
2
+ //#region src/narrate.ts
3
+ /**
4
+ * gs narrate — the explicit TTS prepare step. Provider calls happen here and
5
+ * only here; gs render consumes the committed timing manifest + cached wavs,
6
+ * fully offline.
7
+ */
8
+ async function narrateCommand(opts) {
9
+ const result = await synthesizeScript(scriptPathFor(opts.input), {
10
+ ...opts.provider !== void 0 ? { provider: opts.provider } : {},
11
+ ...opts.force !== void 0 ? { force: opts.force } : {}
12
+ });
13
+ return {
14
+ timingPath: result.timingPath,
15
+ synthesized: result.synthesized,
16
+ reused: result.reused
17
+ };
18
+ }
19
+ //#endregion
20
+ export { narrateCommand };
package/dist/render.js CHANGED
@@ -31,12 +31,15 @@ async function render(opts) {
31
31
  const mod = await loadSceneModule(opts.modulePath);
32
32
  const scene = mod.createScene();
33
33
  const { resolveRenderDoc } = await import("./machines.js").then((n) => n.n);
34
- const doc = resolveRenderDoc(mod, scene, {
34
+ let doc = resolveRenderDoc(mod, scene, {
35
35
  ...opts.trace !== void 0 ? { trace: opts.trace } : {},
36
36
  ...opts.state !== void 0 ? { state: opts.state } : {},
37
37
  ...opts.force !== void 0 ? { force: opts.force } : {}
38
38
  });
39
39
  const fps = opts.fps ?? doc.fps ?? 60;
40
+ const captionsMode = opts.captions ?? "burn";
41
+ const { hideCaptionsDoc, timingPathFor, writeCaptionSidecars } = await import("./captions.js").then((n) => n.t);
42
+ if (captionsMode !== "burn" && scene.resolveTarget("captions/opacity") !== void 0) doc = hideCaptionsDoc(doc);
40
43
  const { compileTimeline } = await import("@glissade/core");
41
44
  const compiled = compileTimeline(doc);
42
45
  const duration = compiled.duration;
@@ -79,8 +82,19 @@ async function render(opts) {
79
82
  }
80
83
  backend.dispose();
81
84
  for (const source of videoSources) source.close();
85
+ const emitSidecars = (target) => {
86
+ if (captionsMode === "off") return;
87
+ const timingPath = timingPathFor(opts.modulePath);
88
+ if (!timingPath) {
89
+ if (captionsMode === "sidecar") process.stderr.write("note: --captions sidecar: no narration timing manifest found; run gs narrate first\n");
90
+ return;
91
+ }
92
+ const { srt, vtt } = writeCaptionSidecars(timingPath, target);
93
+ process.stderr.write(`captions: ${srt}, ${vtt}\n`);
94
+ };
82
95
  if (!isVideo) {
83
96
  if (compiled.audio.length > 0) process.stderr.write("note: PNG-sequence output ignores timeline audio; render to .mp4/.webm to mix it\n");
97
+ emitSidecars(framesDir);
84
98
  return {
85
99
  frames: total,
86
100
  out: framesDir
@@ -88,6 +102,7 @@ async function render(opts) {
88
102
  }
89
103
  const outAbs = resolve(opts.out);
90
104
  mkdirSync(dirname(outAbs), { recursive: true });
105
+ emitSidecars(outAbs);
91
106
  const isWebm = /\.webm$/i.test(outAbs);
92
107
  const container = isWebm ? "webm" : "mp4";
93
108
  const { pickEncoder } = await import("./encoders.js").then((n) => n.r);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -20,11 +20,13 @@
20
20
  "@napi-rs/canvas": "^0.1.65",
21
21
  "esbuild": "^0.28.0",
22
22
  "jiti": "^2.4.2",
23
- "@glissade/backend-skia": "0.2.0",
24
- "@glissade/core": "0.2.0",
25
- "@glissade/interact": "0.2.0",
26
- "@glissade/player": "0.2.0",
27
- "@glissade/scene": "0.2.0"
23
+ "@glissade/backend-skia": "0.4.0",
24
+ "@glissade/core": "0.4.0",
25
+ "@glissade/interact": "0.4.0",
26
+ "@glissade/lottie": "0.4.0",
27
+ "@glissade/narrate": "0.4.0",
28
+ "@glissade/player": "0.4.0",
29
+ "@glissade/scene": "0.4.0"
28
30
  },
29
31
  "repository": {
30
32
  "type": "git",