@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.
@@ -0,0 +1,12 @@
1
+ type Resolve = (specifier: string, context: {
2
+ parentURL?: string;
3
+ }, next: (specifier: string, context: {
4
+ parentURL?: string;
5
+ }) => Promise<{
6
+ url: string;
7
+ }>) => Promise<{
8
+ url: string;
9
+ shortCircuit?: boolean;
10
+ }>;
11
+ export declare const resolve: Resolve;
12
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Module resolution hook registered by the CLI so that user scripts can
3
+ * `import "@reelscript/cli"` even when the package is not installed next to
4
+ * the script (global install, the container, or `npx`). The import resolves
5
+ * to the copy of the library that is running the CLI.
6
+ */
7
+ const SELF = new URL("./index.js", import.meta.url).href;
8
+ export const resolve = async (specifier, context, next) => {
9
+ if (specifier === "@reelscript/cli")
10
+ return { url: SELF, shortCircuit: true };
11
+ return next(specifier, context);
12
+ };
@@ -0,0 +1,38 @@
1
+ /** One chunk of output: when it appeared (ms from command start) and what. */
2
+ export type TermEvent = [atMs: number, text: string];
3
+ export interface TermRecording {
4
+ version: 1;
5
+ command: string;
6
+ cols: number;
7
+ rows: number;
8
+ exitCode: number | null;
9
+ durationMs: number;
10
+ recordedAt: string;
11
+ events: TermEvent[];
12
+ }
13
+ export declare const TERMINAL_URL = "https://reelscript.local/terminal";
14
+ /** Stable file name for a command's recording. */
15
+ export declare function slugForCommand(command: string): string;
16
+ export declare function recordingPath(dir: string, command: string): string;
17
+ export declare function loadRecording(dir: string, command: string): TermRecording | null;
18
+ /** Turn declared output into evenly paced line events. */
19
+ export declare function scriptedEvents(output: string, durationMs?: number): TermEvent[];
20
+ /**
21
+ * Compact a recording's timing for playback: cap long silences, scale by
22
+ * speed, so a real command that stalled for 20s doesn't stall the demo.
23
+ */
24
+ export declare function playbackEvents(events: TermEvent[], opts?: {
25
+ speed?: number;
26
+ maxGapMs?: number;
27
+ }): TermEvent[];
28
+ export interface RecordOptions {
29
+ cwd?: string;
30
+ cols?: number;
31
+ rows?: number;
32
+ timeoutMs?: number;
33
+ }
34
+ /** Run a command for real and capture its output with timestamps. */
35
+ export declare function recordCommand(command: string, opts?: RecordOptions): Promise<TermRecording>;
36
+ export declare function saveRecording(dir: string, rec: TermRecording): string;
37
+ /** Self-contained HTML for the terminal page (xterm + font inlined). */
38
+ export declare function terminalPageHtml(fontSize?: number): string;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Terminal window: an xterm.js page rendered in Chromium with a bundled
3
+ * monospace font, driven by the timeline. Output is either declared in the
4
+ * script or replayed from a recording made by `reelscript record`.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { createRequire } from "node:module";
10
+ import { join } from "node:path";
11
+ export const TERMINAL_URL = "https://reelscript.local/terminal";
12
+ /** Stable file name for a command's recording. */
13
+ export function slugForCommand(command) {
14
+ const base = command
15
+ .toLowerCase()
16
+ .replace(/[^a-z0-9]+/g, "-")
17
+ .replace(/^-+|-+$/g, "")
18
+ .slice(0, 48);
19
+ const hash = createHash("sha1").update(command).digest("hex").slice(0, 6);
20
+ return `${base || "cmd"}-${hash}`;
21
+ }
22
+ export function recordingPath(dir, command) {
23
+ return join(dir, `${slugForCommand(command)}.json`);
24
+ }
25
+ export function loadRecording(dir, command) {
26
+ const file = recordingPath(dir, command);
27
+ if (!existsSync(file))
28
+ return null;
29
+ return JSON.parse(readFileSync(file, "utf8"));
30
+ }
31
+ /** Turn declared output into evenly paced line events. */
32
+ export function scriptedEvents(output, durationMs) {
33
+ const lines = output.replace(/\r\n/g, "\n").split("\n");
34
+ const n = lines.length;
35
+ const total = durationMs ?? Math.min(2500, 150 + 60 * n);
36
+ const step = n > 1 ? total / (n - 1) : 0;
37
+ return lines.map((line, i) => [Math.round(80 + i * step), i < n - 1 ? `${line}\n` : line]);
38
+ }
39
+ /**
40
+ * Compact a recording's timing for playback: cap long silences, scale by
41
+ * speed, so a real command that stalled for 20s doesn't stall the demo.
42
+ */
43
+ export function playbackEvents(events, opts = {}) {
44
+ const speed = opts.speed ?? 1;
45
+ const maxGap = opts.maxGapMs ?? 700;
46
+ let prev = 0;
47
+ let acc = 0;
48
+ return events.map(([at, text]) => {
49
+ const gap = Math.min(Math.max(0, at - prev), maxGap) / speed;
50
+ prev = at;
51
+ acc += gap;
52
+ return [Math.round(acc), text];
53
+ });
54
+ }
55
+ /** Run a command for real and capture its output with timestamps. */
56
+ export function recordCommand(command, opts = {}) {
57
+ const cols = opts.cols ?? 120;
58
+ const rows = opts.rows ?? 36;
59
+ return new Promise((resolve, reject) => {
60
+ const start = Date.now();
61
+ const events = [];
62
+ const child = spawn(command, {
63
+ shell: true,
64
+ cwd: opts.cwd,
65
+ env: { ...process.env, FORCE_COLOR: "1", TERM: "xterm-256color", COLUMNS: String(cols), LINES: String(rows) },
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ });
68
+ const onData = (chunk) => events.push([Date.now() - start, chunk.toString("utf8")]);
69
+ child.stdout.on("data", onData);
70
+ child.stderr.on("data", onData);
71
+ const timer = setTimeout(() => child.kill(), opts.timeoutMs ?? 120_000);
72
+ child.on("error", reject);
73
+ child.on("close", (code) => {
74
+ clearTimeout(timer);
75
+ resolve({
76
+ version: 1,
77
+ command,
78
+ cols,
79
+ rows,
80
+ exitCode: code,
81
+ durationMs: Date.now() - start,
82
+ recordedAt: new Date().toISOString(),
83
+ events,
84
+ });
85
+ });
86
+ });
87
+ }
88
+ export function saveRecording(dir, rec) {
89
+ mkdirSync(dir, { recursive: true });
90
+ const file = recordingPath(dir, rec.command);
91
+ writeFileSync(file, JSON.stringify(rec, null, 2) + "\n");
92
+ return file;
93
+ }
94
+ // ---------------------------------------------------------------- page
95
+ const THEME = {
96
+ background: "#1c1c1e",
97
+ foreground: "#e5e5e7",
98
+ cursor: "#e5e5e7",
99
+ cursorAccent: "#1c1c1e",
100
+ selectionBackground: "#3a3a3c",
101
+ black: "#1c1c1e",
102
+ red: "#ff6b6b",
103
+ green: "#5fd27a",
104
+ yellow: "#f5c451",
105
+ blue: "#6ea8ff",
106
+ magenta: "#d38cff",
107
+ cyan: "#5ed7e0",
108
+ white: "#e5e5e7",
109
+ brightBlack: "#6e6e73",
110
+ brightRed: "#ff8787",
111
+ brightGreen: "#7ee89b",
112
+ brightYellow: "#ffd479",
113
+ brightBlue: "#8fbcff",
114
+ brightMagenta: "#e0a5ff",
115
+ brightCyan: "#7fe6ee",
116
+ brightWhite: "#ffffff",
117
+ };
118
+ let pageCache = new Map();
119
+ /** Self-contained HTML for the terminal page (xterm + font inlined). */
120
+ export function terminalPageHtml(fontSize = 15) {
121
+ let html = pageCache.get(fontSize);
122
+ if (html)
123
+ return html;
124
+ const require = createRequire(import.meta.url);
125
+ const xtermJs = readFileSync(require.resolve("@xterm/xterm/lib/xterm.js"), "utf8");
126
+ const xtermCss = readFileSync(require.resolve("@xterm/xterm/css/xterm.css"), "utf8");
127
+ const fitJs = readFileSync(require.resolve("@xterm/addon-fit/lib/addon-fit.js"), "utf8");
128
+ const font = readFileSync(new URL("../assets/fonts/JetBrainsMono-Variable.ttf", import.meta.url)).toString("base64");
129
+ html = `<!doctype html><html><head><meta charset="utf-8"><style>
130
+ @font-face { font-family: "JetBrains Mono"; font-weight: 100 800; src: url(data:font/ttf;base64,${font}) format("truetype"); }
131
+ ${xtermCss}
132
+ html, body { margin: 0; height: 100%; background: ${THEME.background}; overflow: hidden; }
133
+ #t { position: absolute; inset: 0; padding: 10px 12px; }
134
+ .xterm .xterm-viewport { overflow: hidden !important; }
135
+ </style></head><body><div id="t"></div>
136
+ <script>${xtermJs}</script>
137
+ <script>${fitJs}</script>
138
+ <script>
139
+ (async () => {
140
+ await document.fonts.load('${fontSize}px "JetBrains Mono"');
141
+ const T = window.Terminal || (window.xterm && window.xterm.Terminal);
142
+ const Fit = (window.FitAddon && window.FitAddon.FitAddon) || window.FitAddon;
143
+ const term = new T({
144
+ fontFamily: '"JetBrains Mono", monospace', fontSize: ${fontSize}, lineHeight: 1.3,
145
+ cursorBlink: true, cursorStyle: "block", convertEol: true, scrollback: 0,
146
+ theme: ${JSON.stringify(THEME)},
147
+ });
148
+ const fit = new Fit();
149
+ term.loadAddon(fit);
150
+ term.open(document.getElementById("t"));
151
+ fit.fit();
152
+ term.focus(); // draws the block cursor; unfocused xterm shows only an outline
153
+ window.__rsTerm = {
154
+ write: (s) => { term.write(s); },
155
+ fit: () => { fit.fit(); return { cols: term.cols, rows: term.rows }; },
156
+ get cols() { return term.cols; },
157
+ get rows() { return term.rows; },
158
+ ready: true,
159
+ };
160
+ })();
161
+ </script></body></html>`;
162
+ pageCache.set(fontSize, html);
163
+ return html;
164
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * A theme draws the desktop: the wallpaper and menubar behind everything,
3
+ * a frame (title bar, shadow, rounded corners) around each window, and the
4
+ * mask that rounds a window's content. The engine composes these per frame.
5
+ *
6
+ * Chrome is rendered as HTML by Chromium with fonts bundled in the package,
7
+ * so the same script produces the same pixels on every platform.
8
+ */
9
+ export type ThemeName = "macos" | "bare";
10
+ export type WindowKind = "browser" | "terminal";
11
+ export interface RawImage {
12
+ data: Buffer;
13
+ width: number;
14
+ height: number;
15
+ channels: 4;
16
+ }
17
+ /** A frame image plus where it sits relative to the window's frame origin. */
18
+ export interface FrameImage extends RawImage {
19
+ dx: number;
20
+ dy: number;
21
+ }
22
+ export interface WindowStyle {
23
+ kind: WindowKind;
24
+ /** Content size. */
25
+ width: number;
26
+ height: number;
27
+ title: string;
28
+ url: string;
29
+ focused: boolean;
30
+ }
31
+ /** Renders an HTML document of the given size to a PNG. Provided by the engine. */
32
+ export type HtmlRasterizer = (html: string, width: number, height: number, transparent: boolean) => Promise<Buffer>;
33
+ export interface Theme {
34
+ /** Height of a window's title bar (0 when windows have no chrome). */
35
+ readonly titleHeight: number;
36
+ /** Desktop size when the script doesn't set one, given the main window's content size. */
37
+ defaultDesktop(viewport: [number, number]): [number, number];
38
+ /** Frame origin for the first window opened. */
39
+ mainPlacement(desktop: [number, number], content: [number, number]): {
40
+ x: number;
41
+ y: number;
42
+ };
43
+ background(desktop: [number, number]): Promise<RawImage>;
44
+ /** Window chrome, or null for none. */
45
+ frame(style: WindowStyle): Promise<FrameImage | null>;
46
+ /** Alpha mask applied to window content, or null for none. */
47
+ contentMask(width: number, height: number): Promise<RawImage | null>;
48
+ }
49
+ /** @font-face rule embedding the bundled Inter variable font. */
50
+ export declare function bundledFontFace(): string;
51
+ export declare function createTheme(name: ThemeName, rasterize: HtmlRasterizer): Theme;
package/dist/theme.js ADDED
@@ -0,0 +1,181 @@
1
+ import sharp from "sharp";
2
+ import { readFileSync } from "node:fs";
3
+ function even(n) {
4
+ return n % 2 === 0 ? n : n + 1;
5
+ }
6
+ async function toRaw(png) {
7
+ const { data, info } = await sharp(png).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
8
+ return { data, width: info.width, height: info.height, channels: 4 };
9
+ }
10
+ // ---------------------------------------------------------------- font
11
+ let fontFace = null;
12
+ /** @font-face rule embedding the bundled Inter variable font. */
13
+ export function bundledFontFace() {
14
+ if (!fontFace) {
15
+ const ttf = readFileSync(new URL("../assets/fonts/InterVariable.ttf", import.meta.url));
16
+ fontFace = `@font-face { font-family: "Inter"; font-weight: 100 900; font-style: normal;
17
+ src: url(data:font/ttf;base64,${ttf.toString("base64")}) format("truetype"); }`;
18
+ }
19
+ return fontFace;
20
+ }
21
+ // ---------------------------------------------------------------- bare
22
+ class BareTheme {
23
+ titleHeight = 0;
24
+ bg = null;
25
+ defaultDesktop(viewport) {
26
+ return [even(viewport[0]), even(viewport[1])];
27
+ }
28
+ mainPlacement() {
29
+ return { x: 0, y: 0 };
30
+ }
31
+ async background([w, h]) {
32
+ if (!this.bg || this.bg.width !== w || this.bg.height !== h) {
33
+ const data = Buffer.alloc(w * h * 4);
34
+ for (let i = 3; i < data.length; i += 4)
35
+ data[i] = 255;
36
+ this.bg = { data, width: w, height: h, channels: 4 };
37
+ }
38
+ return this.bg;
39
+ }
40
+ async frame() {
41
+ return null;
42
+ }
43
+ async contentMask() {
44
+ return null;
45
+ }
46
+ }
47
+ // ---------------------------------------------------------------- macos
48
+ const MENUBAR_H = 28;
49
+ const PAD_X = 72;
50
+ const GAP_TOP = 44;
51
+ const TITLE_H = 48;
52
+ const PAD_BOTTOM = 72;
53
+ const RADIUS = 12;
54
+ /** Room around a frame for its drop shadow. */
55
+ const SHADOW_PAD = 64;
56
+ class MacosTheme {
57
+ rasterize;
58
+ titleHeight = TITLE_H;
59
+ bgCache = new Map();
60
+ frameCache = new Map();
61
+ maskCache = new Map();
62
+ constructor(rasterize) {
63
+ this.rasterize = rasterize;
64
+ }
65
+ defaultDesktop([vw, vh]) {
66
+ return [even(vw + PAD_X * 2), even(MENUBAR_H + GAP_TOP + TITLE_H + vh + PAD_BOTTOM)];
67
+ }
68
+ mainPlacement([dw], [w]) {
69
+ return { x: Math.round((dw - w) / 2), y: MENUBAR_H + GAP_TOP };
70
+ }
71
+ css() {
72
+ return `${bundledFontFace()}
73
+ html, body { margin: 0; overflow: hidden; font-family: Inter, system-ui, sans-serif; -webkit-font-smoothing: antialiased; }`;
74
+ }
75
+ background(desktop) {
76
+ const [w, h] = desktop;
77
+ const key = `${w}x${h}`;
78
+ let p = this.bgCache.get(key);
79
+ if (!p) {
80
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>
81
+ ${this.css()}
82
+ html, body { width: ${w}px; height: ${h}px; }
83
+ .wall { position: absolute; inset: 0; background: linear-gradient(135deg, #4f46e5 0%, #c2410c 55%, #f59e0b 100%); }
84
+ .glow { position: absolute; inset: 0; background: radial-gradient(80% 80% at 30% 20%, rgba(255,255,255,.22), rgba(255,255,255,0)); }
85
+ .menubar { position: absolute; left: 0; top: 0; right: 0; height: ${MENUBAR_H}px; background: rgba(255,255,255,.16);
86
+ color: #fff; font-size: 13px; display: flex; align-items: center; justify-content: space-between; padding: 0 18px; }
87
+ .menubar b { font-weight: 600; }
88
+ </style></head><body>
89
+ <div class="wall"></div><div class="glow"></div>
90
+ <div class="menubar"><b>reelscript</b><span>Tue Sep 23&nbsp;&nbsp;9:41 AM</span></div>
91
+ </body></html>`;
92
+ p = this.rasterize(html, w, h, false).then(toRaw);
93
+ this.bgCache.set(key, p);
94
+ }
95
+ return p;
96
+ }
97
+ frame(style) {
98
+ const key = JSON.stringify(style);
99
+ let p = this.frameCache.get(key);
100
+ if (!p) {
101
+ const { kind, width, height, focused } = style;
102
+ const W = width + SHADOW_PAD * 2;
103
+ const H = height + TITLE_H + SHADOW_PAD * 2;
104
+ const terminal = kind === "terminal";
105
+ const urlW = Math.min(560, Math.round(width * 0.46));
106
+ const lights = focused
107
+ ? ["#ff5f57", "#febc2e", "#28c840"]
108
+ : terminal
109
+ ? ["#5a5a5e", "#5a5a5e", "#5a5a5e"]
110
+ : ["#d4d4d8", "#d4d4d8", "#d4d4d8"];
111
+ const bar = terminal
112
+ ? `<div class="ttl">${escapeHtml(style.title)}</div>`
113
+ : `<div class="url">${escapeHtml(displayUrl(style.url))}</div>`;
114
+ const html = `<!doctype html><html><head><meta charset="utf-8"><style>
115
+ ${this.css()}
116
+ html, body { width: ${W}px; height: ${H}px; background: transparent; }
117
+ .window { position: absolute; left: ${SHADOW_PAD}px; top: ${SHADOW_PAD}px; width: ${width}px; height: ${height + TITLE_H}px;
118
+ border-radius: ${RADIUS}px; background: ${terminal ? "#1c1c1e" : "#ffffff"}; overflow: hidden;
119
+ box-shadow: ${focused ? "0 22px 48px rgba(0,0,0,.45), 0 2px 6px rgba(0,0,0,.25)" : "0 12px 28px rgba(0,0,0,.28), 0 1px 4px rgba(0,0,0,.2)"}; }
120
+ .title { position: relative; height: ${TITLE_H}px; background: ${terminal ? "#2c2c2e" : "#f3f3f5"};
121
+ border-bottom: 1px solid ${terminal ? "#3a3a3c" : "#dcdce1"}; }
122
+ .lights { position: absolute; left: 16px; top: ${TITLE_H / 2 - 6}px; display: flex; gap: 8px; }
123
+ .lights i { display: block; width: 12px; height: 12px; border-radius: 50%; }
124
+ .url { position: absolute; left: 50%; top: 12px; transform: translateX(-50%); width: ${urlW}px; height: ${TITLE_H - 24}px;
125
+ border-radius: 7px; background: #e6e6ea; color: ${focused ? "#3f3f46" : "#8e8e93"}; font-size: 12.5px;
126
+ display: flex; align-items: center; justify-content: center; white-space: nowrap; overflow: hidden; }
127
+ .ttl { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
128
+ color: ${focused ? "#a1a1a6" : "#6e6e73"}; font-size: 13px; font-weight: 500; }
129
+ </style></head><body>
130
+ <div class="window"><div class="title">
131
+ <div class="lights"><i style="background:${lights[0]}"></i><i style="background:${lights[1]}"></i><i style="background:${lights[2]}"></i></div>
132
+ ${bar}
133
+ </div></div>
134
+ </body></html>`;
135
+ p = this.rasterize(html, W, H, true)
136
+ .then(toRaw)
137
+ .then((img) => ({ ...img, dx: -SHADOW_PAD, dy: -SHADOW_PAD }));
138
+ this.frameCache.set(key, p);
139
+ }
140
+ return p;
141
+ }
142
+ contentMask(width, height) {
143
+ const key = `${width}x${height}`;
144
+ let p = this.maskCache.get(key);
145
+ if (!p) {
146
+ const r = RADIUS;
147
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
148
+ <path d="M0 0 H${width} V${height - r} A${r} ${r} 0 0 1 ${width - r} ${height} H${r} A${r} ${r} 0 0 1 0 ${height - r} Z" fill="#fff"/>
149
+ </svg>`;
150
+ p = sharp(Buffer.from(svg)).png().toBuffer().then(toRaw);
151
+ this.maskCache.set(key, p);
152
+ }
153
+ return p;
154
+ }
155
+ }
156
+ function displayUrl(url) {
157
+ if (!url)
158
+ return "";
159
+ if (url.startsWith("file://"))
160
+ return url.split("/").pop() ?? url;
161
+ try {
162
+ const u = new URL(url);
163
+ return u.host + (u.pathname === "/" ? "" : u.pathname);
164
+ }
165
+ catch {
166
+ return url;
167
+ }
168
+ }
169
+ function escapeHtml(s) {
170
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
171
+ }
172
+ export function createTheme(name, rasterize) {
173
+ switch (name) {
174
+ case "bare":
175
+ return new BareTheme();
176
+ case "macos":
177
+ return new MacosTheme(rasterize);
178
+ default:
179
+ throw new Error(`reelscript: unknown theme "${name}"`);
180
+ }
181
+ }
@@ -0,0 +1,103 @@
1
+ import type { Ease } from "./easing.js";
2
+ /** A CSS selector, or an explicit point in page (viewport) coordinates. */
3
+ export type Target = string | {
4
+ x: number;
5
+ y: number;
6
+ };
7
+ export type Action = {
8
+ kind: "browser.goto";
9
+ url: string;
10
+ settle?: number;
11
+ } | {
12
+ kind: "browser.mockAPI";
13
+ pattern: string;
14
+ response: unknown;
15
+ status?: number;
16
+ } | {
17
+ kind: "cursor.moveTo";
18
+ target: Target;
19
+ ease?: Ease;
20
+ duration?: number;
21
+ window?: string;
22
+ } | {
23
+ kind: "cursor.click";
24
+ button?: "left" | "right";
25
+ } | {
26
+ kind: "zoom.to";
27
+ target: Target;
28
+ scale?: number;
29
+ duration?: number;
30
+ ease?: Ease;
31
+ window?: string;
32
+ } | {
33
+ kind: "zoom.out";
34
+ duration?: number;
35
+ ease?: Ease;
36
+ } | {
37
+ kind: "type";
38
+ target?: string;
39
+ text: string;
40
+ wpm?: number;
41
+ } | {
42
+ kind: "press";
43
+ key: string;
44
+ } | {
45
+ kind: "wait";
46
+ ms: number;
47
+ } | {
48
+ kind: "say";
49
+ text: string;
50
+ voice?: string;
51
+ speed?: number;
52
+ } | {
53
+ kind: "waitForNarration";
54
+ } | {
55
+ kind: "terminal.open";
56
+ title?: string;
57
+ prompt?: string;
58
+ fontSize?: number;
59
+ x?: number;
60
+ y?: number;
61
+ width?: number;
62
+ height?: number;
63
+ } | {
64
+ kind: "window.focus";
65
+ window: string;
66
+ } | {
67
+ kind: "window.place";
68
+ window: string;
69
+ x?: number;
70
+ y?: number;
71
+ width?: number;
72
+ height?: number;
73
+ } | {
74
+ kind: "terminal.run";
75
+ command: string;
76
+ /** Declared output. Omit to replay a recording made by `reelscript record`. */
77
+ output?: string;
78
+ /** Spread declared output over this many ms. */
79
+ duration?: number;
80
+ /** Typing speed for the command. */
81
+ wpm?: number;
82
+ /** Playback speed for recorded output. Default: 1 */
83
+ speed?: number;
84
+ /** Cap silences in recorded output, ms. Default: 700 */
85
+ maxGapMs?: number;
86
+ };
87
+ export type ActionKind = Action["kind"];
88
+ export declare const DEFAULTS: {
89
+ fps: number;
90
+ viewport: [number, number];
91
+ /** ms the page is shown after a goto before the next action */
92
+ gotoSettle: number;
93
+ clickDuration: number;
94
+ typeWpm: number;
95
+ zoomScale: number;
96
+ zoomDuration: number;
97
+ /** frames appended after the last action so the ending doesn't feel clipped */
98
+ tailMs: number;
99
+ /** silence between consecutive narration clips */
100
+ narrationGapMs: number;
101
+ terminalPrompt: string;
102
+ terminalTitle: string;
103
+ };
@@ -0,0 +1,16 @@
1
+ export const DEFAULTS = {
2
+ fps: 60,
3
+ viewport: [1280, 800],
4
+ /** ms the page is shown after a goto before the next action */
5
+ gotoSettle: 400,
6
+ clickDuration: 180,
7
+ typeWpm: 300,
8
+ zoomScale: 1.6,
9
+ zoomDuration: 700,
10
+ /** frames appended after the last action so the ending doesn't feel clipped */
11
+ tailMs: 500,
12
+ /** silence between consecutive narration clips */
13
+ narrationGapMs: 300,
14
+ terminalPrompt: "~ % ",
15
+ terminalTitle: "zsh",
16
+ };
package/dist/tts.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ export interface TtsAudio {
2
+ /** Mono PCM samples in [-1, 1]. */
3
+ audio: Float32Array;
4
+ sampleRate: number;
5
+ }
6
+ export interface TtsOptions {
7
+ voice?: string;
8
+ /** Playback speed multiplier. Default: 1 */
9
+ speed?: number;
10
+ }
11
+ export interface TtsEngine {
12
+ /** Stable identifier; part of the clip cache key. */
13
+ readonly id: string;
14
+ synthesize(text: string, opts: TtsOptions): Promise<TtsAudio>;
15
+ }
16
+ export interface Clip {
17
+ /** Path to a 16-bit mono WAV file. */
18
+ file: string;
19
+ seconds: number;
20
+ }
21
+ export declare const DEFAULT_VOICE = "af_heart";
22
+ export declare const KOKORO_MODEL = "onnx-community/Kokoro-82M-v1.0-ONNX";
23
+ /** Root of reelscript's on-disk cache (models, synthesized clips). */
24
+ export declare function cacheDir(): string;
25
+ /** Split narration into sentences; TTS models prefer short inputs. */
26
+ export declare function splitSentences(text: string): string[];
27
+ /** Kokoro-82M via kokoro-js, loaded lazily on first use. */
28
+ export declare function kokoro(model?: string): TtsEngine;
29
+ /** Encode mono float samples as a 16-bit PCM WAV file. */
30
+ export declare function toWav({ audio, sampleRate }: TtsAudio): Buffer;
31
+ /** Synthesize (or fetch from cache) one narration clip. */
32
+ export declare function synthesizeClip(engine: TtsEngine, text: string, opts: TtsOptions): Promise<Clip>;
33
+ /** Replace words the TTS mispronounces, e.g. { Reelscript: "Reel script" }. */
34
+ export declare function applyPronunciations(text: string, map: Record<string, string> | undefined): string;