@reelscript/cli 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/dist/cursor.js ADDED
@@ -0,0 +1,55 @@
1
+ import sharp from "sharp";
2
+ const ARROW_VIEW = { w: 20, h: 26 };
3
+ function arrowSvg(px) {
4
+ const s = px / ARROW_VIEW.h;
5
+ const w = Math.ceil(ARROW_VIEW.w * s);
6
+ const h = Math.ceil(ARROW_VIEW.h * s);
7
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${ARROW_VIEW.w} ${ARROW_VIEW.h}">
8
+ <defs>
9
+ <filter id="sh" x="-30%" y="-30%" width="180%" height="180%">
10
+ <feGaussianBlur in="SourceAlpha" stdDeviation="0.9"/>
11
+ <feOffset dx="0.4" dy="1.1" result="b"/>
12
+ <feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
13
+ <feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
14
+ </filter>
15
+ </defs>
16
+ <path filter="url(#sh)" d="M1.5 1.5 L1.5 20.5 L6.2 16 L9.8 23.6 L13.2 22.1 L9.6 14.6 L16.5 14.6 Z"
17
+ fill="#111" stroke="#fff" stroke-width="1.4" stroke-linejoin="round"/>
18
+ </svg>`;
19
+ }
20
+ const cache = new Map();
21
+ export function cursorSprite(heightPx) {
22
+ const px = Math.max(8, Math.round(heightPx));
23
+ const key = `arrow:${px}`;
24
+ let p = cache.get(key);
25
+ if (!p) {
26
+ p = (async () => {
27
+ const svg = arrowSvg(px);
28
+ const { data, info } = await sharp(Buffer.from(svg)).png().toBuffer({ resolveWithObject: true });
29
+ const s = px / ARROW_VIEW.h;
30
+ return { data, width: info.width, height: info.height, hx: 1.5 * s, hy: 1.5 * s };
31
+ })();
32
+ cache.set(key, p);
33
+ }
34
+ return p;
35
+ }
36
+ /** Translucent ring that expands from the click point. */
37
+ export function rippleSprite(radius, opacity) {
38
+ const r = Math.max(2, Math.round(radius));
39
+ const o = Math.round(opacity * 20) / 20;
40
+ const key = `ripple:${r}:${o}`;
41
+ let p = cache.get(key);
42
+ if (!p) {
43
+ p = (async () => {
44
+ const d = r * 2 + 4;
45
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${d}" height="${d}">
46
+ <circle cx="${d / 2}" cy="${d / 2}" r="${r}" fill="#3b82f6" fill-opacity="${o * 0.35}"
47
+ stroke="#3b82f6" stroke-opacity="${o}" stroke-width="2"/>
48
+ </svg>`;
49
+ const { data, info } = await sharp(Buffer.from(svg)).png().toBuffer({ resolveWithObject: true });
50
+ return { data, width: info.width, height: info.height, hx: d / 2, hy: d / 2 };
51
+ })();
52
+ cache.set(key, p);
53
+ }
54
+ return p;
55
+ }
@@ -0,0 +1,6 @@
1
+ export type Ease = "linear" | "smooth" | "snappy" | "overshoot";
2
+ export declare const easings: Record<Ease, (t: number) => number>;
3
+ export declare function clamp(v: number, min: number, max: number): number;
4
+ export declare function lerp(a: number, b: number, t: number): number;
5
+ /** Eased progress in [0,1] for a segment starting at `start` lasting `dur` ms. */
6
+ export declare function progress(t: number, start: number, dur: number, ease: Ease): number;
package/dist/easing.js ADDED
@@ -0,0 +1,25 @@
1
+ export const easings = {
2
+ linear: (t) => t,
3
+ // easeInOutCubic — the default "human hand" feel
4
+ smooth: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2),
5
+ // easeOutExpo — fast start, gentle landing
6
+ snappy: (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),
7
+ // easeOutBack — slight overshoot past the target
8
+ overshoot: (t) => {
9
+ const c1 = 1.70158;
10
+ const c3 = c1 + 1;
11
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
12
+ },
13
+ };
14
+ export function clamp(v, min, max) {
15
+ return v < min ? min : v > max ? max : v;
16
+ }
17
+ export function lerp(a, b, t) {
18
+ return a + (b - a) * t;
19
+ }
20
+ /** Eased progress in [0,1] for a segment starting at `start` lasting `dur` ms. */
21
+ export function progress(t, start, dur, ease) {
22
+ if (dur <= 0)
23
+ return 1;
24
+ return easings[ease](clamp((t - start) / dur, 0, 1));
25
+ }
@@ -0,0 +1,38 @@
1
+ export interface EncoderOptions {
2
+ out: string;
3
+ width: number;
4
+ height: number;
5
+ fps: number;
6
+ /** x264 CRF; lower = higher quality. */
7
+ crf?: number;
8
+ ffmpegPath?: string;
9
+ /** GIF output settings (used when `out` ends in .gif). */
10
+ gif?: GifOptions;
11
+ }
12
+ export interface GifOptions {
13
+ /** Output width in px; height keeps the aspect ratio. Default: 960 */
14
+ width?: number;
15
+ /** GIF frame rate. Default: 20 */
16
+ fps?: number;
17
+ }
18
+ export declare function resolveFfmpeg(explicit?: string): string;
19
+ /** Streams raw RGB frames into ffmpeg and produces an H.264 mp4. */
20
+ export declare class Encoder {
21
+ private opts;
22
+ private proc;
23
+ private stderr;
24
+ private exit;
25
+ readonly frameBytes: number;
26
+ constructor(opts: EncoderOptions);
27
+ start(): void;
28
+ writeFrame(rgb: Buffer): Promise<void>;
29
+ finish(): Promise<void>;
30
+ }
31
+ export interface NarrationCue {
32
+ /** WAV file to play. */
33
+ file: string;
34
+ /** When it starts on the video timeline, in ms. */
35
+ atMs: number;
36
+ }
37
+ /** Remux a finished video with narration clips placed on the timeline (no video re-encode). */
38
+ export declare function muxNarration(videoPath: string, cues: NarrationCue[], out: string, ffmpegPath?: string): Promise<void>;
@@ -0,0 +1,122 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import { mkdirSync } from "node:fs";
4
+ import { dirname, extname } from "node:path";
5
+ import { once } from "node:events";
6
+ /** Codec arguments chosen from the output extension. */
7
+ function outputArgs(opts) {
8
+ const ext = extname(opts.out).toLowerCase();
9
+ if (ext === ".gif") {
10
+ const width = opts.gif?.width ?? 960;
11
+ const fps = opts.gif?.fps ?? 20;
12
+ // Two-pass palette in one graph: sample a palette from the scaled frames,
13
+ // then dither against it. Gives far better colour than ffmpeg's default GIF path.
14
+ const filter = [
15
+ `fps=${fps}`,
16
+ `scale=${width}:-1:flags=lanczos`,
17
+ "split[a][b]",
18
+ "[a]palettegen=stats_mode=diff[p]",
19
+ "[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle",
20
+ ].join(",");
21
+ return ["-filter_complex", filter, "-loop", "0"];
22
+ }
23
+ return [
24
+ "-c:v", "libx264",
25
+ "-preset", "medium",
26
+ "-crf", String(opts.crf ?? 18),
27
+ "-pix_fmt", "yuv420p",
28
+ "-movflags", "+faststart",
29
+ ];
30
+ }
31
+ export function resolveFfmpeg(explicit) {
32
+ if (explicit)
33
+ return explicit;
34
+ if (process.env.REELSCRIPT_FFMPEG)
35
+ return process.env.REELSCRIPT_FFMPEG;
36
+ try {
37
+ const require = createRequire(import.meta.url);
38
+ const p = require("ffmpeg-static");
39
+ if (p)
40
+ return p;
41
+ }
42
+ catch {
43
+ /* fall through to PATH */
44
+ }
45
+ return "ffmpeg";
46
+ }
47
+ /** Streams raw RGB frames into ffmpeg and produces an H.264 mp4. */
48
+ export class Encoder {
49
+ opts;
50
+ proc = null;
51
+ stderr = "";
52
+ exit = null;
53
+ frameBytes;
54
+ constructor(opts) {
55
+ this.opts = opts;
56
+ this.frameBytes = opts.width * opts.height * 3;
57
+ }
58
+ start() {
59
+ const { out, width, height, fps } = this.opts;
60
+ mkdirSync(dirname(out), { recursive: true });
61
+ const args = [
62
+ "-y",
63
+ "-hide_banner",
64
+ "-loglevel", "error",
65
+ "-f", "rawvideo",
66
+ "-pix_fmt", "rgb24",
67
+ "-s", `${width}x${height}`,
68
+ "-r", String(fps),
69
+ "-i", "-",
70
+ ...outputArgs(this.opts),
71
+ out,
72
+ ];
73
+ const proc = spawn(resolveFfmpeg(this.opts.ffmpegPath), args, { stdio: ["pipe", "ignore", "pipe"] });
74
+ proc.stderr.on("data", (d) => (this.stderr += d.toString()));
75
+ this.exit = new Promise((resolve) => proc.on("close", resolve));
76
+ proc.on("error", (err) => {
77
+ this.stderr += `\nfailed to start ffmpeg: ${err.message}`;
78
+ });
79
+ this.proc = proc;
80
+ }
81
+ async writeFrame(rgb) {
82
+ if (!this.proc?.stdin)
83
+ throw new Error("encoder not started");
84
+ if (rgb.length !== this.frameBytes) {
85
+ throw new Error(`frame is ${rgb.length} bytes, expected ${this.frameBytes}`);
86
+ }
87
+ if (!this.proc.stdin.write(rgb)) {
88
+ await once(this.proc.stdin, "drain");
89
+ }
90
+ }
91
+ async finish() {
92
+ if (!this.proc?.stdin)
93
+ return;
94
+ this.proc.stdin.end();
95
+ const code = await this.exit;
96
+ if (code !== 0) {
97
+ throw new Error(`ffmpeg exited with code ${code}\n${this.stderr.trim()}`);
98
+ }
99
+ }
100
+ }
101
+ /** Remux a finished video with narration clips placed on the timeline (no video re-encode). */
102
+ export async function muxNarration(videoPath, cues, out, ffmpegPath) {
103
+ const args = ["-y", "-hide_banner", "-loglevel", "error", "-i", videoPath];
104
+ for (const c of cues)
105
+ args.push("-i", c.file);
106
+ const delayed = cues.map((c, i) => `[${i + 1}:a]adelay=${Math.max(0, Math.round(c.atMs))}:all=1[a${i}]`);
107
+ const labels = cues.map((_, i) => `[a${i}]`).join("");
108
+ const filter = cues.length === 1
109
+ ? delayed[0]
110
+ : [...delayed, `${labels}amix=inputs=${cues.length}:normalize=0[mix]`].join(";");
111
+ const outLabel = cues.length === 1 ? "[a0]" : "[mix]";
112
+ args.push("-filter_complex", filter, "-map", "0:v", "-map", outLabel, "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-movflags", "+faststart", out);
113
+ const proc = spawn(resolveFfmpeg(ffmpegPath), args, { stdio: ["ignore", "ignore", "pipe"] });
114
+ let stderr = "";
115
+ proc.stderr.on("data", (d) => (stderr += d.toString()));
116
+ const code = await new Promise((resolve, reject) => {
117
+ proc.on("error", reject);
118
+ proc.on("close", resolve);
119
+ });
120
+ if (code !== 0)
121
+ throw new Error(`ffmpeg (narration mux) exited with code ${code}\n${stderr.trim()}`);
122
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * reelscript — product demos as code.
3
+ *
4
+ * Scripts build a timeline through the `Demo` API; `render()` executes it
5
+ * against a headless Chromium, samples the state at a fixed frame rate, and
6
+ * composites the animated cursor + zoom offline before encoding to mp4.
7
+ */
8
+ import { type RenderResult } from "./renderer.js";
9
+ import type { Action, Target } from "./timeline.js";
10
+ import type { Ease } from "./easing.js";
11
+ import type { ThemeName } from "./theme.js";
12
+ import type { GifOptions } from "./encoder.js";
13
+ import type { TtsEngine } from "./tts.js";
14
+ export type { Action, Target } from "./timeline.js";
15
+ export type { Ease } from "./easing.js";
16
+ export type { ThemeName } from "./theme.js";
17
+ export type { RenderOptions, RenderResult } from "./renderer.js";
18
+ export type { GifOptions } from "./encoder.js";
19
+ export type { TtsEngine, TtsAudio, TtsOptions } from "./tts.js";
20
+ export { kokoro } from "./tts.js";
21
+ export type { TermEvent, TermRecording } from "./terminal.js";
22
+ export { recordCommand, scriptedEvents, playbackEvents } from "./terminal.js";
23
+ export { render } from "./renderer.js";
24
+ export interface DemoOptions {
25
+ /** Visual chrome around the recorded page. Default: "macos". */
26
+ theme?: ThemeName;
27
+ /** Content size of the first window, in CSS pixels. Default: [1280, 800]. */
28
+ viewport?: [number, number];
29
+ /** Desktop (output) size. Default: the first window plus the theme's margins. */
30
+ desktop?: [number, number];
31
+ /** Output frames per second. Default: 60. */
32
+ fps?: number;
33
+ /** Freeze the page clock and step it per frame for reproducible animations. Default: true. */
34
+ deterministic?: boolean;
35
+ /** Applied when rendering to a .gif path. Default: 960px wide at 20fps. */
36
+ gif?: GifOptions;
37
+ /** Default narration voice for say(). Default: "af_heart" (Kokoro). */
38
+ voice?: string;
39
+ /** Text-to-speech engine. Default: Kokoro via the optional kokoro-js dependency. */
40
+ tts?: TtsEngine;
41
+ /** Respell words the voice mispronounces, e.g. { Reelscript: "Reel script" }. */
42
+ pronunciations?: Record<string, string>;
43
+ /** Where terminal recordings are stored. Default: `recordings/` next to the script. */
44
+ recordingsDir?: string;
45
+ /** Print render progress to stderr. Default: true. */
46
+ verbose?: boolean;
47
+ }
48
+ export interface MoveOptions {
49
+ ease?: Ease;
50
+ /** Movement duration in ms. Default: derived from distance. */
51
+ duration?: number;
52
+ /** Window to resolve a selector in. Default: the focused window. */
53
+ window?: "browser" | "terminal";
54
+ }
55
+ /** Window position (frame origin, desktop pixels) and content size. */
56
+ export interface WindowGeometry {
57
+ x?: number;
58
+ y?: number;
59
+ width?: number;
60
+ height?: number;
61
+ }
62
+ export interface ZoomOptions {
63
+ /** Default: 1.6 */
64
+ scale?: number;
65
+ /** Transition duration in ms. Default: 700 */
66
+ duration?: number;
67
+ ease?: Ease;
68
+ /** Window to resolve a selector in. Default: the focused window. */
69
+ window?: "browser" | "terminal";
70
+ }
71
+ export interface TypeOptions {
72
+ /** Typing speed in words per minute. Default: 300 */
73
+ wpm?: number;
74
+ }
75
+ export interface SayOptions {
76
+ voice?: string;
77
+ /** Speed multiplier. Default: 1 */
78
+ speed?: number;
79
+ }
80
+ export interface TerminalOptions {
81
+ /** Window title. Default: "zsh" */
82
+ title?: string;
83
+ /** Prompt string. Default: "~ % " */
84
+ prompt?: string;
85
+ /** Default: 15 */
86
+ fontSize?: number;
87
+ }
88
+ export interface RunOptions {
89
+ /** Output to show. Omit to replay a recording made with `reelscript record`. */
90
+ output?: string;
91
+ /** Spread declared output over this many ms. */
92
+ duration?: number;
93
+ /** Typing speed for the command. Default: 300 */
94
+ wpm?: number;
95
+ /** Playback speed for recorded output. Default: 1 */
96
+ speed?: number;
97
+ /** Cap silences in recorded output, in ms. Default: 700 */
98
+ maxGapMs?: number;
99
+ }
100
+ export interface GotoOptions {
101
+ /** ms to hold on the freshly loaded page. Default: 400 */
102
+ settle?: number;
103
+ }
104
+ export interface MockOptions {
105
+ status?: number;
106
+ }
107
+ declare class Cursor {
108
+ private demo;
109
+ constructor(demo: Demo);
110
+ /** Glide the cursor to a selector or point. */
111
+ moveTo(target: Target, opts?: MoveOptions): Promise<void>;
112
+ click(opts?: {
113
+ button?: "left" | "right";
114
+ }): Promise<void>;
115
+ }
116
+ declare class Zoom {
117
+ private demo;
118
+ constructor(demo: Demo);
119
+ /** Animate a zoom centered on a selector or point. Runs alongside following actions. */
120
+ to(target: Target, opts?: ZoomOptions): void;
121
+ out(opts?: Omit<ZoomOptions, "scale">): void;
122
+ }
123
+ declare class Win {
124
+ protected demo: Demo;
125
+ readonly id: "browser" | "terminal";
126
+ constructor(demo: Demo, id: "browser" | "terminal");
127
+ /** Bring this window to the front and direct typing to it. */
128
+ focus(): Promise<void>;
129
+ /** Move or resize this window. */
130
+ place(geometry: WindowGeometry): Promise<void>;
131
+ }
132
+ declare class Browser extends Win {
133
+ constructor(demo: Demo);
134
+ /** Navigate the browser window (opening it if needed) and bring it to the front. */
135
+ goto(url: string, opts?: GotoOptions): Promise<void>;
136
+ /** Intercept matching requests and return canned JSON — demos never hit a live backend. */
137
+ mockAPI(pattern: string, response: unknown, opts?: MockOptions): void;
138
+ }
139
+ declare class TerminalWindow extends Win {
140
+ constructor(demo: Demo);
141
+ /** Open a terminal window on the desktop (beside or over the browser) and focus it. */
142
+ open(opts?: TerminalOptions & WindowGeometry): Promise<void>;
143
+ /**
144
+ * Type a command and show its output. With `output`, nothing executes;
145
+ * without it, the output is replayed from a recording (see `reelscript record`).
146
+ */
147
+ run(command: string, opts?: RunOptions): Promise<void>;
148
+ }
149
+ export declare class Demo {
150
+ readonly options: DemoOptions;
151
+ readonly cursor: Cursor;
152
+ readonly zoom: Zoom;
153
+ readonly browser: Browser;
154
+ readonly terminal: TerminalWindow;
155
+ private actions;
156
+ constructor(options?: DemoOptions);
157
+ /** @internal */
158
+ _push(action: Action): void;
159
+ /** Type into a field with accelerated, evenly paced keystrokes. */
160
+ type(target: string, text: string, opts?: TypeOptions): Promise<void>;
161
+ /** Press a key or chord, e.g. "Enter" or "Meta+K". */
162
+ press(key: string): Promise<void>;
163
+ wait(ms: number): Promise<void>;
164
+ /**
165
+ * Narrate. Starts speaking now (or when the previous sentence finishes)
166
+ * while the following actions continue. Use waitForNarration() to hold
167
+ * the timeline until speech ends.
168
+ */
169
+ say(text: string, opts?: SayOptions): void;
170
+ /** Hold until all narration queued so far has finished. */
171
+ waitForNarration(): Promise<void>;
172
+ /** The recorded timeline (useful for tests and debugging). */
173
+ getTimeline(): readonly Action[];
174
+ /**
175
+ * Render the timeline to a video file. The container is chosen from the
176
+ * extension: .mp4 (H.264) or .gif (palette-optimized).
177
+ *
178
+ * Honors REELSCRIPT_OUT (override output path) and REELSCRIPT_SNAPSHOT_AT
179
+ * (render a single PNG at that time in ms) so the CLI can drive scripts.
180
+ */
181
+ /** Directory for terminal recordings: option, else `recordings/` beside the entry script. */
182
+ recordingsDir(): string;
183
+ /** Run every terminal command that has no declared output and save its recording. */
184
+ recordTerminals(): Promise<string[]>;
185
+ render(outPath: string): Promise<RenderResult>;
186
+ }
187
+ export declare function createDemo(options?: DemoOptions): Demo;
package/dist/index.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * reelscript — product demos as code.
3
+ *
4
+ * Scripts build a timeline through the `Demo` API; `render()` executes it
5
+ * against a headless Chromium, samples the state at a fixed frame rate, and
6
+ * composites the animated cursor + zoom offline before encoding to mp4.
7
+ */
8
+ import { render as renderTimeline } from "./renderer.js";
9
+ import { recordCommand, saveRecording } from "./terminal.js";
10
+ import { dirname, join, resolve } from "node:path";
11
+ export { kokoro } from "./tts.js";
12
+ export { recordCommand, scriptedEvents, playbackEvents } from "./terminal.js";
13
+ export { render } from "./renderer.js";
14
+ class Cursor {
15
+ demo;
16
+ constructor(demo) {
17
+ this.demo = demo;
18
+ }
19
+ /** Glide the cursor to a selector or point. */
20
+ async moveTo(target, opts = {}) {
21
+ this.demo._push({ kind: "cursor.moveTo", target, ...opts });
22
+ }
23
+ async click(opts = {}) {
24
+ this.demo._push({ kind: "cursor.click", ...opts });
25
+ }
26
+ }
27
+ class Zoom {
28
+ demo;
29
+ constructor(demo) {
30
+ this.demo = demo;
31
+ }
32
+ /** Animate a zoom centered on a selector or point. Runs alongside following actions. */
33
+ to(target, opts = {}) {
34
+ this.demo._push({ kind: "zoom.to", target, ...opts });
35
+ }
36
+ out(opts = {}) {
37
+ this.demo._push({ kind: "zoom.out", ...opts });
38
+ }
39
+ }
40
+ class Win {
41
+ demo;
42
+ id;
43
+ constructor(demo, id) {
44
+ this.demo = demo;
45
+ this.id = id;
46
+ }
47
+ /** Bring this window to the front and direct typing to it. */
48
+ async focus() {
49
+ this.demo._push({ kind: "window.focus", window: this.id });
50
+ }
51
+ /** Move or resize this window. */
52
+ async place(geometry) {
53
+ this.demo._push({ kind: "window.place", window: this.id, ...geometry });
54
+ }
55
+ }
56
+ class Browser extends Win {
57
+ constructor(demo) {
58
+ super(demo, "browser");
59
+ }
60
+ /** Navigate the browser window (opening it if needed) and bring it to the front. */
61
+ async goto(url, opts = {}) {
62
+ this.demo._push({ kind: "browser.goto", url, ...opts });
63
+ }
64
+ /** Intercept matching requests and return canned JSON — demos never hit a live backend. */
65
+ mockAPI(pattern, response, opts = {}) {
66
+ this.demo._push({ kind: "browser.mockAPI", pattern, response, ...opts });
67
+ }
68
+ }
69
+ class TerminalWindow extends Win {
70
+ constructor(demo) {
71
+ super(demo, "terminal");
72
+ }
73
+ /** Open a terminal window on the desktop (beside or over the browser) and focus it. */
74
+ async open(opts = {}) {
75
+ this.demo._push({ kind: "terminal.open", ...opts });
76
+ }
77
+ /**
78
+ * Type a command and show its output. With `output`, nothing executes;
79
+ * without it, the output is replayed from a recording (see `reelscript record`).
80
+ */
81
+ async run(command, opts = {}) {
82
+ this.demo._push({ kind: "terminal.run", command, ...opts });
83
+ }
84
+ }
85
+ export class Demo {
86
+ options;
87
+ cursor = new Cursor(this);
88
+ zoom = new Zoom(this);
89
+ browser = new Browser(this);
90
+ terminal = new TerminalWindow(this);
91
+ actions = [];
92
+ constructor(options = {}) {
93
+ this.options = options;
94
+ }
95
+ /** @internal */
96
+ _push(action) {
97
+ this.actions.push(action);
98
+ }
99
+ /** Type into a field with accelerated, evenly paced keystrokes. */
100
+ async type(target, text, opts = {}) {
101
+ this._push({ kind: "type", target, text, ...opts });
102
+ }
103
+ /** Press a key or chord, e.g. "Enter" or "Meta+K". */
104
+ async press(key) {
105
+ this._push({ kind: "press", key });
106
+ }
107
+ async wait(ms) {
108
+ this._push({ kind: "wait", ms });
109
+ }
110
+ /**
111
+ * Narrate. Starts speaking now (or when the previous sentence finishes)
112
+ * while the following actions continue. Use waitForNarration() to hold
113
+ * the timeline until speech ends.
114
+ */
115
+ say(text, opts = {}) {
116
+ this._push({ kind: "say", text, ...opts });
117
+ }
118
+ /** Hold until all narration queued so far has finished. */
119
+ async waitForNarration() {
120
+ this._push({ kind: "waitForNarration" });
121
+ }
122
+ /** The recorded timeline (useful for tests and debugging). */
123
+ getTimeline() {
124
+ return this.actions;
125
+ }
126
+ /**
127
+ * Render the timeline to a video file. The container is chosen from the
128
+ * extension: .mp4 (H.264) or .gif (palette-optimized).
129
+ *
130
+ * Honors REELSCRIPT_OUT (override output path) and REELSCRIPT_SNAPSHOT_AT
131
+ * (render a single PNG at that time in ms) so the CLI can drive scripts.
132
+ */
133
+ /** Directory for terminal recordings: option, else `recordings/` beside the entry script. */
134
+ recordingsDir() {
135
+ if (this.options.recordingsDir)
136
+ return resolve(this.options.recordingsDir);
137
+ const script = process.env.REELSCRIPT_SCRIPT || process.argv[1] || ".";
138
+ return join(dirname(resolve(script)), "recordings");
139
+ }
140
+ /** Run every terminal command that has no declared output and save its recording. */
141
+ async recordTerminals() {
142
+ const dir = this.recordingsDir();
143
+ const files = [];
144
+ for (const a of this.actions) {
145
+ if (a.kind !== "terminal.run" || a.output !== undefined)
146
+ continue;
147
+ process.stderr.write(`reelscript: recording "${a.command}"\n`);
148
+ const rec = await recordCommand(a.command);
149
+ files.push(saveRecording(dir, rec));
150
+ }
151
+ return files;
152
+ }
153
+ async render(outPath) {
154
+ if (process.env.REELSCRIPT_RECORD) {
155
+ const files = await this.recordTerminals();
156
+ process.stderr.write(files.length
157
+ ? `reelscript: saved ${files.length} recording${files.length === 1 ? "" : "s"} in ${this.recordingsDir()}\n`
158
+ : "reelscript: nothing to record (no terminal.run without output)\n");
159
+ return { out: "", frames: 0, durationMs: 0, width: 0, height: 0 };
160
+ }
161
+ const out = process.env.REELSCRIPT_OUT || outPath;
162
+ const snapRaw = process.env.REELSCRIPT_SNAPSHOT_AT;
163
+ const snapshotAt = snapRaw ? Number(snapRaw) : undefined;
164
+ const verbose = this.options.verbose ?? true;
165
+ const started = Date.now();
166
+ const result = await renderTimeline(this.actions, {
167
+ out,
168
+ fps: this.options.fps,
169
+ viewport: this.options.viewport,
170
+ desktop: this.options.desktop,
171
+ theme: this.options.theme,
172
+ deterministic: this.options.deterministic,
173
+ gif: this.options.gif,
174
+ tts: this.options.tts,
175
+ voice: this.options.voice,
176
+ pronunciations: this.options.pronunciations,
177
+ snapshotAt,
178
+ recordingsDir: this.recordingsDir(),
179
+ onStatus: verbose ? (m) => process.stderr.write(`reelscript: ${m}\n`) : undefined,
180
+ onProgress: verbose
181
+ ? ({ frame, timeMs }) => {
182
+ if (frame > 0 && frame % 30 === 0) {
183
+ process.stderr.write(`\rreelscript: frame ${frame} t=${(timeMs / 1000).toFixed(2)}s`);
184
+ }
185
+ }
186
+ : undefined,
187
+ });
188
+ if (verbose) {
189
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
190
+ process.stderr.write(`\rreelscript: wrote ${result.out} (${result.width}x${result.height}, ${result.frames} frames, ${(result.durationMs / 1000).toFixed(2)}s video, rendered in ${secs}s)\n`);
191
+ }
192
+ return result;
193
+ }
194
+ }
195
+ export function createDemo(options = {}) {
196
+ return new Demo(options);
197
+ }
@@ -0,0 +1,48 @@
1
+ import { type Action } from "./timeline.js";
2
+ import { type ThemeName } from "./theme.js";
3
+ import { type GifOptions } from "./encoder.js";
4
+ import { type TtsEngine } from "./tts.js";
5
+ export interface RenderOptions {
6
+ out: string;
7
+ fps?: number;
8
+ /** Content size of the first window. */
9
+ viewport?: [number, number];
10
+ /** Desktop (output) size. Default: first window plus theme margins. */
11
+ desktop?: [number, number];
12
+ theme?: ThemeName;
13
+ /** Settings applied when `out` ends in .gif. */
14
+ gif?: GifOptions;
15
+ /** Text-to-speech engine for say(). Default: Kokoro via kokoro-js. */
16
+ tts?: TtsEngine;
17
+ /** Default narration voice. Default: "af_heart" */
18
+ voice?: string;
19
+ /** Words to respell before synthesis, e.g. { Reelscript: "Reel script" }. */
20
+ pronunciations?: Record<string, string>;
21
+ onStatus?: (message: string) => void;
22
+ /** Where terminal recordings live (for terminal.run without declared output). */
23
+ recordingsDir?: string;
24
+ /**
25
+ * Replace the page's clock with a virtual one that advances exactly one
26
+ * frame per rendered frame, so CSS transitions, timers and rAF loops play
27
+ * at the correct speed regardless of how long each frame takes to capture.
28
+ * Default: true.
29
+ */
30
+ deterministic?: boolean;
31
+ /**
32
+ * Render up to this time (ms) and write that single frame as a PNG to `out`
33
+ * instead of encoding a video. Fast way to iterate on a moment of a demo.
34
+ */
35
+ snapshotAt?: number;
36
+ onProgress?: (info: {
37
+ frame: number;
38
+ timeMs: number;
39
+ }) => void;
40
+ }
41
+ export interface RenderResult {
42
+ out: string;
43
+ frames: number;
44
+ durationMs: number;
45
+ width: number;
46
+ height: number;
47
+ }
48
+ export declare function render(actions: Action[], options: RenderOptions): Promise<RenderResult>;