astroshot 0.2.1
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 +87 -0
- package/bin/astroshot.mjs +71 -0
- package/node_modules/@archastro/astroshot/LICENSE +21 -0
- package/node_modules/@archastro/astroshot/README.md +96 -0
- package/node_modules/@archastro/astroshot/bin/astroshot.mjs +276 -0
- package/node_modules/@archastro/astroshot/bin/demo.mjs +275 -0
- package/node_modules/@archastro/astroshot/bin/doctor.mjs +492 -0
- package/node_modules/@archastro/astroshot/bin/mac-preferences.mjs +346 -0
- package/node_modules/@archastro/astroshot/bin/templates.mjs +186 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/fixtures.json +42 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/journey.png +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/journey.webm +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/next-steps.png +0 -0
- package/node_modules/@archastro/astroshot/fixtures/demo/welcome.png +0 -0
- package/node_modules/@archastro/astroshot/ink.d.ts +5 -0
- package/node_modules/@archastro/astroshot/ink.js +1 -0
- package/node_modules/@archastro/astroshot/package.json +79 -0
- package/node_modules/@archastro/astroshot/pty.d.ts +8 -0
- package/node_modules/@archastro/astroshot/pty.js +1 -0
- package/node_modules/@archastro/astroshot/react.d.ts +1 -0
- package/node_modules/@archastro/astroshot/react.js +1 -0
- package/node_modules/@archastro/astroshot/tui.d.ts +1 -0
- package/node_modules/@archastro/astroshot/tui.js +1 -0
- package/node_modules/@archastro/movie-harness/README.md +138 -0
- package/node_modules/@archastro/movie-harness/bin/astroshot-movie.mjs +5 -0
- package/node_modules/@archastro/movie-harness/dist/cli.d.ts +1 -0
- package/node_modules/@archastro/movie-harness/dist/cli.js +389 -0
- package/node_modules/@archastro/movie-harness/dist/encode.d.ts +14 -0
- package/node_modules/@archastro/movie-harness/dist/encode.js +184 -0
- package/node_modules/@archastro/movie-harness/dist/index.d.ts +14 -0
- package/node_modules/@archastro/movie-harness/dist/index.js +11 -0
- package/node_modules/@archastro/movie-harness/dist/paths.d.ts +9 -0
- package/node_modules/@archastro/movie-harness/dist/paths.js +57 -0
- package/node_modules/@archastro/movie-harness/dist/png.d.ts +8 -0
- package/node_modules/@archastro/movie-harness/dist/png.js +79 -0
- package/node_modules/@archastro/movie-harness/dist/session.d.ts +45 -0
- package/node_modules/@archastro/movie-harness/dist/session.js +170 -0
- package/node_modules/@archastro/movie-harness/dist/sink.d.ts +7 -0
- package/node_modules/@archastro/movie-harness/dist/sink.js +113 -0
- package/node_modules/@archastro/movie-harness/dist/source-help.d.ts +29 -0
- package/node_modules/@archastro/movie-harness/dist/source-help.js +222 -0
- package/node_modules/@archastro/movie-harness/dist/sources/browser.d.ts +7 -0
- package/node_modules/@archastro/movie-harness/dist/sources/browser.js +87 -0
- package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.d.ts +110 -0
- package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.js +505 -0
- package/node_modules/@archastro/movie-harness/dist/sources/frames-store.d.ts +19 -0
- package/node_modules/@archastro/movie-harness/dist/sources/frames-store.js +165 -0
- package/node_modules/@archastro/movie-harness/dist/sources/pty.d.ts +15 -0
- package/node_modules/@archastro/movie-harness/dist/sources/pty.js +330 -0
- package/node_modules/@archastro/movie-harness/dist/terminal-paint.d.ts +27 -0
- package/node_modules/@archastro/movie-harness/dist/terminal-paint.js +190 -0
- package/node_modules/@archastro/movie-harness/dist/types.d.ts +140 -0
- package/node_modules/@archastro/movie-harness/dist/types.js +1 -0
- package/node_modules/@archastro/movie-harness/native/macos/WindowTools.swift +164 -0
- package/node_modules/@archastro/movie-harness/package.json +67 -0
- package/node_modules/@archastro/react-shot/LICENSE +21 -0
- package/node_modules/@archastro/react-shot/README.md +181 -0
- package/node_modules/@archastro/react-shot/bin/react-shot.mjs +2 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.js +34 -0
- package/node_modules/@archastro/react-shot/dist/batch-paths.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/cli.d.ts +3 -0
- package/node_modules/@archastro/react-shot/dist/cli.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/cli.js +217 -0
- package/node_modules/@archastro/react-shot/dist/cli.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/config.d.ts +5 -0
- package/node_modules/@archastro/react-shot/dist/config.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/config.js +75 -0
- package/node_modules/@archastro/react-shot/dist/config.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/create-server.d.ts +13 -0
- package/node_modules/@archastro/react-shot/dist/create-server.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/create-server.js +254 -0
- package/node_modules/@archastro/react-shot/dist/create-server.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/index.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/index.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/index.js +3 -0
- package/node_modules/@archastro/react-shot/dist/index.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/meta.d.ts +9 -0
- package/node_modules/@archastro/react-shot/dist/meta.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/meta.js +23 -0
- package/node_modules/@archastro/react-shot/dist/meta.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/shot.d.ts +4 -0
- package/node_modules/@archastro/react-shot/dist/shot.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/shot.js +259 -0
- package/node_modules/@archastro/react-shot/dist/shot.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/stubs.d.ts +6 -0
- package/node_modules/@archastro/react-shot/dist/stubs.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/stubs.js +49 -0
- package/node_modules/@archastro/react-shot/dist/stubs.js.map +1 -0
- package/node_modules/@archastro/react-shot/dist/types.d.ts +79 -0
- package/node_modules/@archastro/react-shot/dist/types.d.ts.map +1 -0
- package/node_modules/@archastro/react-shot/dist/types.js +2 -0
- package/node_modules/@archastro/react-shot/dist/types.js.map +1 -0
- package/node_modules/@archastro/react-shot/host/index.html +22 -0
- package/node_modules/@archastro/react-shot/package.json +81 -0
- package/node_modules/@archastro/tui-shot/LICENSE +21 -0
- package/node_modules/@archastro/tui-shot/README.md +145 -0
- package/node_modules/@archastro/tui-shot/bin/tui-shot.mjs +23 -0
- package/node_modules/@archastro/tui-shot/dist/batch-paths.d.ts +3 -0
- package/node_modules/@archastro/tui-shot/dist/batch-paths.js +33 -0
- package/node_modules/@archastro/tui-shot/dist/cli.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/cli.js +224 -0
- package/node_modules/@archastro/tui-shot/dist/index.d.ts +3 -0
- package/node_modules/@archastro/tui-shot/dist/index.js +2 -0
- package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.d.ts +1 -0
- package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.js +50 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.js +483 -0
- package/node_modules/@archastro/tui-shot/dist/render-ink.d.ts +10 -0
- package/node_modules/@archastro/tui-shot/dist/render-ink.js +87 -0
- package/node_modules/@archastro/tui-shot/dist/shot.d.ts +24 -0
- package/node_modules/@archastro/tui-shot/dist/shot.js +250 -0
- package/node_modules/@archastro/tui-shot/dist/terminal-html.d.ts +14 -0
- package/node_modules/@archastro/tui-shot/dist/terminal-html.js +144 -0
- package/node_modules/@archastro/tui-shot/dist/types.d.ts +81 -0
- package/node_modules/@archastro/tui-shot/dist/types.js +1 -0
- package/node_modules/@archastro/tui-shot/package.json +90 -0
- package/package.json +68 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MovieArtifact, MovieSessionOptions, PtyMovieFixture } from "../types.js";
|
|
2
|
+
export declare function loadPtyMovieFixture(fixturePath: string): PtyMovieFixture;
|
|
3
|
+
export type PtyMovieSessionOptions = Omit<MovieSessionOptions, "source"> & {
|
|
4
|
+
fixturePath: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Run a PTY fixture, sample truecolor terminal frames into a MovieSession,
|
|
8
|
+
* and publish poster + video. Color path: SGR → xterm cells → HTML → Chromium.
|
|
9
|
+
*/
|
|
10
|
+
export declare function recordPtyMovie(options: PtyMovieSessionOptions): Promise<MovieArtifact>;
|
|
11
|
+
/** Synthetic truecolor PTY-like movie without node-pty (for CI smoke). */
|
|
12
|
+
export declare function recordTruecolorDemoMovie(options: Omit<MovieSessionOptions, "source"> & {
|
|
13
|
+
/** Hex color without #, default 7c5cff */
|
|
14
|
+
color?: string;
|
|
15
|
+
}): Promise<MovieArtifact>;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
import { MovieSession } from "../session.js";
|
|
6
|
+
import { createHeadlessTerminal, terminalDocument, terminalPlainText, terminalToHtml, writeTerminal, } from "../terminal-paint.js";
|
|
7
|
+
const KEYSTROKES = {
|
|
8
|
+
enter: "\r",
|
|
9
|
+
up: "\x1b[A",
|
|
10
|
+
down: "\x1b[B",
|
|
11
|
+
right: "\x1b[C",
|
|
12
|
+
left: "\x1b[D",
|
|
13
|
+
tab: "\t",
|
|
14
|
+
escape: "\x1b",
|
|
15
|
+
backspace: "\x7f",
|
|
16
|
+
space: " ",
|
|
17
|
+
"ctrl-c": "\x03",
|
|
18
|
+
"ctrl-d": "\x04",
|
|
19
|
+
};
|
|
20
|
+
function delay(ms) {
|
|
21
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
function isRecord(value) {
|
|
24
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
export function loadPtyMovieFixture(fixturePath) {
|
|
27
|
+
const absolute = path.resolve(fixturePath);
|
|
28
|
+
if (!fs.existsSync(absolute)) {
|
|
29
|
+
throw new Error(`PTY fixture not found: ${absolute}`);
|
|
30
|
+
}
|
|
31
|
+
const text = fs.readFileSync(absolute, "utf8");
|
|
32
|
+
const value = absolute.endsWith(".json") ? JSON.parse(text) : YAML.parse(text);
|
|
33
|
+
if (!isRecord(value)) {
|
|
34
|
+
throw new Error(`Invalid PTY fixture ${absolute}: document must be object`);
|
|
35
|
+
}
|
|
36
|
+
if (value.version !== 1) {
|
|
37
|
+
throw new Error(`Invalid PTY fixture ${absolute}: version must be 1`);
|
|
38
|
+
}
|
|
39
|
+
if (typeof value.command !== "string" || !value.command.trim()) {
|
|
40
|
+
throw new Error(`Invalid PTY fixture ${absolute}: command required`);
|
|
41
|
+
}
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Run a PTY fixture, sample truecolor terminal frames into a MovieSession,
|
|
46
|
+
* and publish poster + video. Color path: SGR → xterm cells → HTML → Chromium.
|
|
47
|
+
*/
|
|
48
|
+
export async function recordPtyMovie(options) {
|
|
49
|
+
const fixture = loadPtyMovieFixture(options.fixturePath);
|
|
50
|
+
const fixtureDir = path.dirname(path.resolve(options.fixturePath));
|
|
51
|
+
const cols = fixture.cols ?? 80;
|
|
52
|
+
const rows = fixture.rows ?? 24;
|
|
53
|
+
const fontSize = fixture.fontSize ?? 14;
|
|
54
|
+
const lineHeight = fixture.lineHeight ?? 1.35;
|
|
55
|
+
const padding = fixture.padding ?? 16;
|
|
56
|
+
const borderRadius = fixture.borderRadius ?? 12;
|
|
57
|
+
const scale = fixture.scale ?? 2;
|
|
58
|
+
const background = fixture.background ?? "#090a12";
|
|
59
|
+
const foreground = fixture.foreground ?? "#e8e8f2";
|
|
60
|
+
const fontFamily = fixture.fontFamily ??
|
|
61
|
+
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace";
|
|
62
|
+
const fps = fixture.movieFps ?? options.fps ?? 12;
|
|
63
|
+
const timeoutMs = fixture.timeoutMs ?? 30_000;
|
|
64
|
+
const settleMs = fixture.settleMs ?? 80;
|
|
65
|
+
const paint = {
|
|
66
|
+
cols,
|
|
67
|
+
rows,
|
|
68
|
+
foreground,
|
|
69
|
+
background,
|
|
70
|
+
fontFamily,
|
|
71
|
+
fontSize,
|
|
72
|
+
lineHeight,
|
|
73
|
+
padding,
|
|
74
|
+
borderRadius,
|
|
75
|
+
scale,
|
|
76
|
+
};
|
|
77
|
+
const { cssWidth, cssHeight } = terminalDocument("", paint);
|
|
78
|
+
const size = {
|
|
79
|
+
width: cssWidth + 32,
|
|
80
|
+
height: cssHeight + 32,
|
|
81
|
+
};
|
|
82
|
+
const session = MovieSession.create({
|
|
83
|
+
...options,
|
|
84
|
+
size,
|
|
85
|
+
fps,
|
|
86
|
+
source: "pty",
|
|
87
|
+
});
|
|
88
|
+
const terminal = createHeadlessTerminal(cols, rows);
|
|
89
|
+
let browser;
|
|
90
|
+
let page;
|
|
91
|
+
let sampleTimer;
|
|
92
|
+
let sampling = Promise.resolve();
|
|
93
|
+
try {
|
|
94
|
+
browser = await chromium.launch({ headless: true });
|
|
95
|
+
page = await browser.newPage({
|
|
96
|
+
viewport: size,
|
|
97
|
+
deviceScaleFactor: scale,
|
|
98
|
+
});
|
|
99
|
+
const sample = async () => {
|
|
100
|
+
if (!page)
|
|
101
|
+
return;
|
|
102
|
+
const rowsHtml = terminalToHtml(terminal, {
|
|
103
|
+
cols,
|
|
104
|
+
rows,
|
|
105
|
+
foreground,
|
|
106
|
+
background,
|
|
107
|
+
});
|
|
108
|
+
const { html } = terminalDocument(rowsHtml, paint);
|
|
109
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
110
|
+
const png = await page.locator("[data-tui-shot]").screenshot({
|
|
111
|
+
type: "png",
|
|
112
|
+
omitBackground: true,
|
|
113
|
+
});
|
|
114
|
+
session.pushFrame(png);
|
|
115
|
+
};
|
|
116
|
+
const intervalMs = Math.max(40, Math.round(1000 / fps));
|
|
117
|
+
sampleTimer = setInterval(() => {
|
|
118
|
+
sampling = sampling.then(sample).catch(() => undefined);
|
|
119
|
+
}, intervalMs);
|
|
120
|
+
await runPtyProgram({
|
|
121
|
+
fixture,
|
|
122
|
+
fixtureDir,
|
|
123
|
+
terminal,
|
|
124
|
+
cols,
|
|
125
|
+
rows,
|
|
126
|
+
timeoutMs,
|
|
127
|
+
settleMs,
|
|
128
|
+
});
|
|
129
|
+
clearInterval(sampleTimer);
|
|
130
|
+
sampleTimer = undefined;
|
|
131
|
+
await sampling;
|
|
132
|
+
// Final frame after settle — must match still-shot color path.
|
|
133
|
+
await sample();
|
|
134
|
+
return await session.stop({ status: options.status ?? "running" });
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
if (sampleTimer)
|
|
138
|
+
clearInterval(sampleTimer);
|
|
139
|
+
await page?.close().catch(() => undefined);
|
|
140
|
+
await browser?.close().catch(() => undefined);
|
|
141
|
+
terminal.dispose();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function runPtyProgram(args) {
|
|
145
|
+
const { fixture, fixtureDir, terminal, cols, rows, timeoutMs, settleMs } = args;
|
|
146
|
+
let spawnPty;
|
|
147
|
+
try {
|
|
148
|
+
({ spawn: spawnPty } = await import("node-pty"));
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
throw new Error("PTY movie capture requires the optional node-pty native addon.", { cause: error });
|
|
152
|
+
}
|
|
153
|
+
const cwd = fixture.cwd
|
|
154
|
+
? path.resolve(fixtureDir, fixture.cwd)
|
|
155
|
+
: fixtureDir;
|
|
156
|
+
const command = resolveCommand(fixture.command, cwd);
|
|
157
|
+
const childEnv = {
|
|
158
|
+
...process.env,
|
|
159
|
+
TERM: "xterm-256color",
|
|
160
|
+
COLORTERM: "truecolor",
|
|
161
|
+
...fixture.env,
|
|
162
|
+
};
|
|
163
|
+
let writes = Promise.resolve();
|
|
164
|
+
let exited = null;
|
|
165
|
+
let resolveExit = null;
|
|
166
|
+
const exitPromise = new Promise((resolve) => {
|
|
167
|
+
resolveExit = resolve;
|
|
168
|
+
});
|
|
169
|
+
const child = spawnPty(command, fixture.args ?? [], {
|
|
170
|
+
name: "xterm-256color",
|
|
171
|
+
cols,
|
|
172
|
+
rows,
|
|
173
|
+
cwd,
|
|
174
|
+
env: childEnv,
|
|
175
|
+
});
|
|
176
|
+
child.onData((data) => {
|
|
177
|
+
writes = writes.then(() => writeTerminal(terminal, data));
|
|
178
|
+
});
|
|
179
|
+
child.onExit((event) => {
|
|
180
|
+
exited = event;
|
|
181
|
+
resolveExit?.();
|
|
182
|
+
});
|
|
183
|
+
const deadline = Date.now() + timeoutMs;
|
|
184
|
+
const screen = () => terminalPlainText(terminal, rows);
|
|
185
|
+
const remaining = () => Math.max(0, deadline - Date.now());
|
|
186
|
+
try {
|
|
187
|
+
for (const action of fixture.actions ?? []) {
|
|
188
|
+
if (remaining() === 0) {
|
|
189
|
+
throw new Error(`PTY movie exceeded timeoutMs ${timeoutMs}`);
|
|
190
|
+
}
|
|
191
|
+
await runAction(action, {
|
|
192
|
+
child,
|
|
193
|
+
writes: () => writes,
|
|
194
|
+
screen,
|
|
195
|
+
remaining,
|
|
196
|
+
deadline,
|
|
197
|
+
exited: () => exited,
|
|
198
|
+
exitPromise,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
await delay(Math.min(settleMs, remaining()));
|
|
202
|
+
await writes;
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
try {
|
|
206
|
+
child.kill();
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
/* already dead */
|
|
210
|
+
}
|
|
211
|
+
// Drain a short window so dispose is clean.
|
|
212
|
+
await Promise.race([exitPromise, delay(500)]);
|
|
213
|
+
}
|
|
214
|
+
for (const expected of fixture.expectText ?? []) {
|
|
215
|
+
if (!screen().includes(expected)) {
|
|
216
|
+
throw new Error(`PTY movie did not render expected text ${JSON.stringify(expected)}. ` +
|
|
217
|
+
`Visible:\n${screen().slice(0, 1200)}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
// Exit code is advisory: we may kill the child after sampling. Non-zero
|
|
221
|
+
// before expectText is still surfaced via missing text / timeouts above.
|
|
222
|
+
void exited;
|
|
223
|
+
void fixture.allowNonZeroExit;
|
|
224
|
+
}
|
|
225
|
+
async function runAction(action, ctx) {
|
|
226
|
+
if ("waitFor" in action) {
|
|
227
|
+
const actionDeadline = Math.min(ctx.deadline, Date.now() + (action.timeoutMs ?? 120_000));
|
|
228
|
+
while (Date.now() <= actionDeadline) {
|
|
229
|
+
await ctx.writes();
|
|
230
|
+
if (ctx.screen().includes(action.waitFor))
|
|
231
|
+
return;
|
|
232
|
+
if (ctx.exited())
|
|
233
|
+
break;
|
|
234
|
+
await delay(20);
|
|
235
|
+
}
|
|
236
|
+
throw new Error(`Timed out waiting for ${JSON.stringify(action.waitFor)}. Visible:\n${ctx.screen().slice(0, 1200)}`);
|
|
237
|
+
}
|
|
238
|
+
if ("waitForExit" in action) {
|
|
239
|
+
const actionDeadline = Math.min(ctx.deadline, Date.now() + (action.timeoutMs ?? 120_000));
|
|
240
|
+
while (Date.now() <= actionDeadline) {
|
|
241
|
+
if (ctx.exited())
|
|
242
|
+
return;
|
|
243
|
+
await Promise.race([ctx.exitPromise, delay(20)]);
|
|
244
|
+
}
|
|
245
|
+
throw new Error("Timed out waiting for PTY exit");
|
|
246
|
+
}
|
|
247
|
+
if ("key" in action) {
|
|
248
|
+
ctx.child.write(KEYSTROKES[action.key]);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if ("text" in action) {
|
|
252
|
+
ctx.child.write(action.text);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await delay(Math.min(action.pauseMs, ctx.remaining()));
|
|
256
|
+
}
|
|
257
|
+
function resolveCommand(command, cwd) {
|
|
258
|
+
if (command.includes(path.sep) || command.startsWith(".")) {
|
|
259
|
+
return path.resolve(cwd, command);
|
|
260
|
+
}
|
|
261
|
+
// PATH lookup — node-pty resolves bare names on most platforms.
|
|
262
|
+
return command;
|
|
263
|
+
}
|
|
264
|
+
/** Synthetic truecolor PTY-like movie without node-pty (for CI smoke). */
|
|
265
|
+
export async function recordTruecolorDemoMovie(options) {
|
|
266
|
+
const color = options.color ?? "7c5cff";
|
|
267
|
+
const r = Number.parseInt(color.slice(0, 2), 16);
|
|
268
|
+
const g = Number.parseInt(color.slice(2, 4), 16);
|
|
269
|
+
const b = Number.parseInt(color.slice(4, 6), 16);
|
|
270
|
+
if ([r, g, b].some((n) => Number.isNaN(n))) {
|
|
271
|
+
throw new Error(`invalid color ${color}`);
|
|
272
|
+
}
|
|
273
|
+
const cols = 40;
|
|
274
|
+
const rows = 8;
|
|
275
|
+
const paint = {
|
|
276
|
+
cols,
|
|
277
|
+
rows,
|
|
278
|
+
foreground: "#e8e8f2",
|
|
279
|
+
background: "#090a12",
|
|
280
|
+
fontFamily: "ui-monospace, monospace",
|
|
281
|
+
fontSize: 16,
|
|
282
|
+
lineHeight: 1.35,
|
|
283
|
+
padding: 16,
|
|
284
|
+
borderRadius: 12,
|
|
285
|
+
scale: 2,
|
|
286
|
+
};
|
|
287
|
+
const { cssWidth, cssHeight } = terminalDocument("", paint);
|
|
288
|
+
const size = { width: cssWidth + 32, height: cssHeight + 32 };
|
|
289
|
+
const session = MovieSession.create({
|
|
290
|
+
...options,
|
|
291
|
+
size,
|
|
292
|
+
fps: options.fps ?? 10,
|
|
293
|
+
source: "pty",
|
|
294
|
+
});
|
|
295
|
+
const terminal = createHeadlessTerminal(cols, rows);
|
|
296
|
+
const browser = await chromium.launch({ headless: true });
|
|
297
|
+
try {
|
|
298
|
+
const page = await browser.newPage({
|
|
299
|
+
viewport: size,
|
|
300
|
+
deviceScaleFactor: 2,
|
|
301
|
+
});
|
|
302
|
+
const labels = ["truecolor", "movie", "harness", color];
|
|
303
|
+
for (const label of labels) {
|
|
304
|
+
await writeTerminal(terminal, `\x1b[2J\x1b[H\x1b[38;2;${r};${g};${b}m${label}\x1b[0m\r\n`);
|
|
305
|
+
const rowsHtml = terminalToHtml(terminal, {
|
|
306
|
+
cols,
|
|
307
|
+
rows,
|
|
308
|
+
foreground: paint.foreground,
|
|
309
|
+
background: paint.background,
|
|
310
|
+
});
|
|
311
|
+
if (!rowsHtml.includes(`color:#${color}`)) {
|
|
312
|
+
throw new Error(`truecolor path lost #${color}; html=${rowsHtml.slice(0, 200)}`);
|
|
313
|
+
}
|
|
314
|
+
const { html } = terminalDocument(rowsHtml, paint);
|
|
315
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
316
|
+
const png = await page.locator("[data-tui-shot]").screenshot({
|
|
317
|
+
type: "png",
|
|
318
|
+
omitBackground: true,
|
|
319
|
+
});
|
|
320
|
+
session.pushFrame(png);
|
|
321
|
+
await delay(120);
|
|
322
|
+
}
|
|
323
|
+
await page.close();
|
|
324
|
+
return await session.stop({ status: options.status ?? "running" });
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
terminal.dispose();
|
|
328
|
+
await browser.close().catch(() => undefined);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truecolor ANSI → HTML paint path (same model as tui-shot terminal-html).
|
|
3
|
+
* Color fidelity is a hard requirement for PTY movies.
|
|
4
|
+
*/
|
|
5
|
+
import { type Terminal as TerminalType } from "@xterm/headless";
|
|
6
|
+
export type HeadlessTerminal = TerminalType;
|
|
7
|
+
export interface TerminalPaintOptions {
|
|
8
|
+
cols: number;
|
|
9
|
+
rows: number;
|
|
10
|
+
foreground: string;
|
|
11
|
+
background: string;
|
|
12
|
+
fontFamily: string;
|
|
13
|
+
fontSize: number;
|
|
14
|
+
lineHeight: number;
|
|
15
|
+
padding: number;
|
|
16
|
+
borderRadius: number;
|
|
17
|
+
scale: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function createHeadlessTerminal(cols: number, rows: number): HeadlessTerminal;
|
|
20
|
+
export declare function writeTerminal(terminal: HeadlessTerminal, data: string): Promise<void>;
|
|
21
|
+
export declare function terminalPlainText(terminal: HeadlessTerminal, rows: number): string;
|
|
22
|
+
export declare function terminalToHtml(terminal: HeadlessTerminal, options: Pick<TerminalPaintOptions, "cols" | "rows" | "foreground" | "background">): string;
|
|
23
|
+
export declare function terminalDocument(terminalRows: string, options: TerminalPaintOptions): {
|
|
24
|
+
html: string;
|
|
25
|
+
cssWidth: number;
|
|
26
|
+
cssHeight: number;
|
|
27
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Truecolor ANSI → HTML paint path (same model as tui-shot terminal-html).
|
|
3
|
+
* Color fidelity is a hard requirement for PTY movies.
|
|
4
|
+
*/
|
|
5
|
+
import xtermHeadless from "@xterm/headless";
|
|
6
|
+
const { Terminal } = xtermHeadless;
|
|
7
|
+
const ANSI_16 = [
|
|
8
|
+
"#2b2f3a",
|
|
9
|
+
"#f0727a",
|
|
10
|
+
"#54e0a0",
|
|
11
|
+
"#f0b86e",
|
|
12
|
+
"#7aa2f7",
|
|
13
|
+
"#b9a8ff",
|
|
14
|
+
"#5bd6e0",
|
|
15
|
+
"#e8e8f2",
|
|
16
|
+
"#5a5a7a",
|
|
17
|
+
"#ff8d94",
|
|
18
|
+
"#78efb6",
|
|
19
|
+
"#ffd08a",
|
|
20
|
+
"#9bbcff",
|
|
21
|
+
"#d1c5ff",
|
|
22
|
+
"#86eaf0",
|
|
23
|
+
"#ffffff",
|
|
24
|
+
];
|
|
25
|
+
function rgb(red, green, blue) {
|
|
26
|
+
return `#${[red, green, blue]
|
|
27
|
+
.map((value) => value.toString(16).padStart(2, "0"))
|
|
28
|
+
.join("")}`;
|
|
29
|
+
}
|
|
30
|
+
function paletteColor(index) {
|
|
31
|
+
if (index < ANSI_16.length)
|
|
32
|
+
return ANSI_16[index];
|
|
33
|
+
if (index < 232) {
|
|
34
|
+
const value = index - 16;
|
|
35
|
+
const levels = [0, 95, 135, 175, 215, 255];
|
|
36
|
+
return rgb(levels[Math.floor(value / 36)], levels[Math.floor((value % 36) / 6)], levels[value % 6]);
|
|
37
|
+
}
|
|
38
|
+
const gray = 8 + (index - 232) * 10;
|
|
39
|
+
return rgb(gray, gray, gray);
|
|
40
|
+
}
|
|
41
|
+
function packedRgb(value) {
|
|
42
|
+
return rgb((value >> 16) & 255, (value >> 8) & 255, value & 255);
|
|
43
|
+
}
|
|
44
|
+
function escapeHtml(value) {
|
|
45
|
+
return value
|
|
46
|
+
.replaceAll("&", "&")
|
|
47
|
+
.replaceAll("<", "<")
|
|
48
|
+
.replaceAll(">", ">")
|
|
49
|
+
.replaceAll('"', """);
|
|
50
|
+
}
|
|
51
|
+
function cellStyle(cell, defaultForeground, defaultBackground) {
|
|
52
|
+
let foreground = cell.isFgRGB()
|
|
53
|
+
? packedRgb(cell.getFgColor())
|
|
54
|
+
: cell.isFgPalette()
|
|
55
|
+
? paletteColor(cell.getFgColor())
|
|
56
|
+
: defaultForeground;
|
|
57
|
+
let background = cell.isBgRGB()
|
|
58
|
+
? packedRgb(cell.getBgColor())
|
|
59
|
+
: cell.isBgPalette()
|
|
60
|
+
? paletteColor(cell.getBgColor())
|
|
61
|
+
: defaultBackground;
|
|
62
|
+
if (cell.isInverse())
|
|
63
|
+
[foreground, background] = [background, foreground];
|
|
64
|
+
return {
|
|
65
|
+
foreground,
|
|
66
|
+
background,
|
|
67
|
+
bold: Boolean(cell.isBold()),
|
|
68
|
+
dim: Boolean(cell.isDim()),
|
|
69
|
+
italic: Boolean(cell.isItalic()),
|
|
70
|
+
underline: Boolean(cell.isUnderline()),
|
|
71
|
+
strike: Boolean(cell.isStrikethrough()),
|
|
72
|
+
invisible: Boolean(cell.isInvisible()),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function styleAttribute(style) {
|
|
76
|
+
return [
|
|
77
|
+
`color:${style.invisible ? style.background : style.foreground}`,
|
|
78
|
+
`background:${style.background}`,
|
|
79
|
+
style.bold ? "font-weight:700" : "",
|
|
80
|
+
style.dim ? "opacity:.62" : "",
|
|
81
|
+
style.italic ? "font-style:italic" : "",
|
|
82
|
+
style.underline && style.strike
|
|
83
|
+
? "text-decoration:underline line-through"
|
|
84
|
+
: style.underline
|
|
85
|
+
? "text-decoration:underline"
|
|
86
|
+
: style.strike
|
|
87
|
+
? "text-decoration:line-through"
|
|
88
|
+
: "",
|
|
89
|
+
]
|
|
90
|
+
.filter(Boolean)
|
|
91
|
+
.join(";");
|
|
92
|
+
}
|
|
93
|
+
export function createHeadlessTerminal(cols, rows) {
|
|
94
|
+
return new Terminal({
|
|
95
|
+
cols,
|
|
96
|
+
rows,
|
|
97
|
+
allowProposedApi: true,
|
|
98
|
+
convertEol: true,
|
|
99
|
+
scrollback: 0,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
export function writeTerminal(terminal, data) {
|
|
103
|
+
return new Promise((resolve) => terminal.write(data, resolve));
|
|
104
|
+
}
|
|
105
|
+
export function terminalPlainText(terminal, rows) {
|
|
106
|
+
const buffer = terminal.buffer.active;
|
|
107
|
+
const lines = [];
|
|
108
|
+
for (let y = 0; y < rows; y++) {
|
|
109
|
+
const line = buffer.getLine(buffer.viewportY + y);
|
|
110
|
+
lines.push(line?.translateToString(true) ?? "");
|
|
111
|
+
}
|
|
112
|
+
return lines.join("\n").trimEnd();
|
|
113
|
+
}
|
|
114
|
+
export function terminalToHtml(terminal, options) {
|
|
115
|
+
const buffer = terminal.buffer.active;
|
|
116
|
+
const rows = [];
|
|
117
|
+
for (let y = 0; y < options.rows; y++) {
|
|
118
|
+
const line = buffer.getLine(buffer.viewportY + y);
|
|
119
|
+
const runs = [];
|
|
120
|
+
for (let x = 0; x < options.cols; x++) {
|
|
121
|
+
const cell = line?.getCell(x);
|
|
122
|
+
if (cell?.getWidth() === 0)
|
|
123
|
+
continue;
|
|
124
|
+
const style = cell
|
|
125
|
+
? cellStyle(cell, options.foreground, options.background)
|
|
126
|
+
: {
|
|
127
|
+
foreground: options.foreground,
|
|
128
|
+
background: options.background,
|
|
129
|
+
bold: false,
|
|
130
|
+
dim: false,
|
|
131
|
+
italic: false,
|
|
132
|
+
underline: false,
|
|
133
|
+
strike: false,
|
|
134
|
+
invisible: false,
|
|
135
|
+
};
|
|
136
|
+
const key = JSON.stringify(style);
|
|
137
|
+
const text = cell?.getChars() || " ";
|
|
138
|
+
const previous = runs.at(-1);
|
|
139
|
+
if (previous?.key === key)
|
|
140
|
+
previous.text += text;
|
|
141
|
+
else
|
|
142
|
+
runs.push({ key, style, text });
|
|
143
|
+
}
|
|
144
|
+
rows.push(`<div class="tui-row">${runs
|
|
145
|
+
.map((run) => `<span style="${styleAttribute(run.style)}">${escapeHtml(run.text)}</span>`)
|
|
146
|
+
.join("")}</div>`);
|
|
147
|
+
}
|
|
148
|
+
return rows.join("");
|
|
149
|
+
}
|
|
150
|
+
export function terminalDocument(terminalRows, options) {
|
|
151
|
+
const cssWidth = Math.ceil(options.cols * options.fontSize * 0.62 + options.padding * 2);
|
|
152
|
+
const cssHeight = Math.ceil(options.rows * options.fontSize * options.lineHeight + options.padding * 2);
|
|
153
|
+
const html = `<!doctype html>
|
|
154
|
+
<html>
|
|
155
|
+
<head>
|
|
156
|
+
<meta charset="utf-8" />
|
|
157
|
+
<style>
|
|
158
|
+
html, body { margin: 0; padding: 0; background: transparent; }
|
|
159
|
+
body { display: inline-block; padding: 16px; }
|
|
160
|
+
[data-tui-shot] {
|
|
161
|
+
box-sizing: border-box;
|
|
162
|
+
width: ${cssWidth}px;
|
|
163
|
+
height: ${cssHeight}px;
|
|
164
|
+
overflow: hidden;
|
|
165
|
+
padding: ${options.padding}px;
|
|
166
|
+
border: 1px solid rgba(185, 168, 255, .18);
|
|
167
|
+
border-radius: ${options.borderRadius}px;
|
|
168
|
+
background: ${options.background};
|
|
169
|
+
color: ${options.foreground};
|
|
170
|
+
box-shadow: 0 18px 44px rgba(0, 0, 0, .28);
|
|
171
|
+
font-family: ${options.fontFamily};
|
|
172
|
+
font-size: ${options.fontSize}px;
|
|
173
|
+
font-variant-ligatures: none;
|
|
174
|
+
font-weight: 400;
|
|
175
|
+
line-height: ${options.lineHeight};
|
|
176
|
+
text-rendering: geometricPrecision;
|
|
177
|
+
}
|
|
178
|
+
.tui-row {
|
|
179
|
+
height: ${options.lineHeight}em;
|
|
180
|
+
overflow: hidden;
|
|
181
|
+
white-space: pre;
|
|
182
|
+
}
|
|
183
|
+
</style>
|
|
184
|
+
</head>
|
|
185
|
+
<body>
|
|
186
|
+
<div data-tui-shot role="img" aria-label="Terminal screenshot">${terminalRows}</div>
|
|
187
|
+
</body>
|
|
188
|
+
</html>`;
|
|
189
|
+
return { html, cssWidth, cssHeight };
|
|
190
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export type MovieSourceKind = "frames" | "browser" | "pty" | "desktop.window" | "desktop.display" | "desktop.region";
|
|
2
|
+
export type MovieFormat = "webm" | "mp4";
|
|
3
|
+
export type ManifestStatus = "running" | "pass" | "fail" | "idle";
|
|
4
|
+
export interface Size {
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
export interface MovieChapter {
|
|
9
|
+
slug: string;
|
|
10
|
+
tMs: number;
|
|
11
|
+
note?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface MovieArtifact {
|
|
14
|
+
videoPath: string;
|
|
15
|
+
posterPath: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
chapters: MovieChapter[];
|
|
18
|
+
source: MovieSourceKind;
|
|
19
|
+
feature: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
sequence: string;
|
|
22
|
+
runId: string;
|
|
23
|
+
}
|
|
24
|
+
export interface MovieSessionOptions {
|
|
25
|
+
/** Feature directory under .astroshot/ (kebab-case). */
|
|
26
|
+
feature: string;
|
|
27
|
+
/** Filename slug for this movie. */
|
|
28
|
+
slug: string;
|
|
29
|
+
/** Worktree / project root that contains (or will contain) .astroshot/. */
|
|
30
|
+
root?: string;
|
|
31
|
+
/** Stable run id; defaults to feature-timestamp-pid. */
|
|
32
|
+
runId?: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
size?: Size;
|
|
36
|
+
/** Target encode frame rate. Default 15. */
|
|
37
|
+
fps?: number;
|
|
38
|
+
/** Output container. Default webm (Playwright-native). */
|
|
39
|
+
format?: MovieFormat;
|
|
40
|
+
/** Manifest status while recording. Default running. */
|
|
41
|
+
status?: ManifestStatus;
|
|
42
|
+
source: MovieSourceKind;
|
|
43
|
+
}
|
|
44
|
+
export interface EncodeFramesRequest {
|
|
45
|
+
framePaths: string[];
|
|
46
|
+
outPath: string;
|
|
47
|
+
size: Size;
|
|
48
|
+
fps: number;
|
|
49
|
+
/** Wall-clock duration override when frame timestamps are irregular. */
|
|
50
|
+
durationMs?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface SinkMovieRequest {
|
|
53
|
+
root: string;
|
|
54
|
+
feature: string;
|
|
55
|
+
slug: string;
|
|
56
|
+
runId: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
description?: string;
|
|
59
|
+
status?: ManifestStatus;
|
|
60
|
+
source: MovieSourceKind;
|
|
61
|
+
posterPath: string;
|
|
62
|
+
videoPath: string;
|
|
63
|
+
durationMs: number;
|
|
64
|
+
chapters: MovieChapter[];
|
|
65
|
+
size?: Size;
|
|
66
|
+
}
|
|
67
|
+
export interface SinkMovieResult {
|
|
68
|
+
sequence: string;
|
|
69
|
+
posterDest: string;
|
|
70
|
+
videoDest: string;
|
|
71
|
+
manifestPath: string;
|
|
72
|
+
featureDir: string;
|
|
73
|
+
}
|
|
74
|
+
/** On-disk state for multi-process frames CLI (start / push / mark / stop). */
|
|
75
|
+
export interface PersistedFrameSession {
|
|
76
|
+
version: 1;
|
|
77
|
+
id: string;
|
|
78
|
+
feature: string;
|
|
79
|
+
slug: string;
|
|
80
|
+
root: string;
|
|
81
|
+
runId: string;
|
|
82
|
+
title?: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
size: Size;
|
|
85
|
+
fps: number;
|
|
86
|
+
format: MovieFormat;
|
|
87
|
+
source: "frames";
|
|
88
|
+
startedAtMs: number;
|
|
89
|
+
frameDir: string;
|
|
90
|
+
frameCount: number;
|
|
91
|
+
chapters: MovieChapter[];
|
|
92
|
+
}
|
|
93
|
+
export type PtyKey = "enter" | "up" | "down" | "left" | "right" | "tab" | "escape" | "backspace" | "space" | "ctrl-c" | "ctrl-d";
|
|
94
|
+
export type PtyAction = {
|
|
95
|
+
waitFor: string;
|
|
96
|
+
timeoutMs?: number;
|
|
97
|
+
} | {
|
|
98
|
+
waitForExit: true;
|
|
99
|
+
timeoutMs?: number;
|
|
100
|
+
} | {
|
|
101
|
+
key: PtyKey;
|
|
102
|
+
} | {
|
|
103
|
+
text: string;
|
|
104
|
+
} | {
|
|
105
|
+
pauseMs: number;
|
|
106
|
+
};
|
|
107
|
+
export interface PtyMovieFixture {
|
|
108
|
+
version: 1;
|
|
109
|
+
command: string;
|
|
110
|
+
args?: string[];
|
|
111
|
+
cwd?: string;
|
|
112
|
+
env?: Record<string, string>;
|
|
113
|
+
cols?: number;
|
|
114
|
+
rows?: number;
|
|
115
|
+
timeoutMs?: number;
|
|
116
|
+
settleMs?: number;
|
|
117
|
+
allowNonZeroExit?: boolean;
|
|
118
|
+
actions?: PtyAction[];
|
|
119
|
+
expectText?: string[];
|
|
120
|
+
background?: string;
|
|
121
|
+
foreground?: string;
|
|
122
|
+
fontFamily?: string;
|
|
123
|
+
fontSize?: number;
|
|
124
|
+
lineHeight?: number;
|
|
125
|
+
padding?: number;
|
|
126
|
+
borderRadius?: number;
|
|
127
|
+
scale?: number;
|
|
128
|
+
/** Sample rate for the movie. Defaults to session fps. */
|
|
129
|
+
movieFps?: number;
|
|
130
|
+
}
|
|
131
|
+
export interface BrowserMovieOptions {
|
|
132
|
+
/** Navigate here when the harness owns the page. */
|
|
133
|
+
url?: string;
|
|
134
|
+
/** Optional script module that exports `default` async (page) => void. */
|
|
135
|
+
scriptPath?: string;
|
|
136
|
+
/** Reuse an existing headed browser (debug). Default headless. */
|
|
137
|
+
headed?: boolean;
|
|
138
|
+
/** How long to keep recording after script ends (ms). Default 0. */
|
|
139
|
+
settleMs?: number;
|
|
140
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|