@archastro/movie-harness 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,380 @@
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { MovieSession } from "../session.js";
7
+ const SCREENCAPTURE = "/usr/sbin/screencapture";
8
+ /** Known deep-links to the Screen Recording privacy pane (varies by macOS). */
9
+ const SCREEN_RECORDING_SETTINGS_URLS = [
10
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
11
+ "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture",
12
+ ];
13
+ function packageNativeSwift() {
14
+ const here = path.dirname(fileURLToPath(import.meta.url));
15
+ // dist/sources -> ../../native/macos
16
+ return path.resolve(here, "../../native/macos/WindowTools.swift");
17
+ }
18
+ function assertMacOS() {
19
+ if (process.platform !== "darwin") {
20
+ throw new Error("desktop.window is only implemented on macOS (CGWindowList + screencapture). " +
21
+ "On other platforms use --source frames and push your own captures. " +
22
+ "See: astroshot movie which-source");
23
+ }
24
+ }
25
+ function assertScreencapture() {
26
+ if (!fs.existsSync(SCREENCAPTURE)) {
27
+ throw new Error(`screencapture not found at ${SCREENCAPTURE}`);
28
+ }
29
+ }
30
+ function runWindowTools(args) {
31
+ const script = packageNativeSwift();
32
+ if (!fs.existsSync(script)) {
33
+ throw new Error(`WindowTools.swift missing at ${script}. Reinstall @archastro/movie-harness.`);
34
+ }
35
+ const result = spawnSync("swift", [script, ...args], {
36
+ encoding: "utf8",
37
+ maxBuffer: 8 * 1024 * 1024,
38
+ });
39
+ if (result.error) {
40
+ throw new Error(`Could not run Swift window tools (${result.error.message}). ` +
41
+ "Install Xcode Command Line Tools (`xcode-select --install`).");
42
+ }
43
+ return {
44
+ status: result.status,
45
+ stdout: result.stdout ?? "",
46
+ stderr: result.stderr ?? "",
47
+ };
48
+ }
49
+ /**
50
+ * Open System Settings to the Screen Recording privacy list.
51
+ * Best-effort: URL schemes differ slightly across macOS versions.
52
+ */
53
+ export function openScreenRecordingSettings() {
54
+ assertMacOS();
55
+ for (const url of SCREEN_RECORDING_SETTINGS_URLS) {
56
+ const result = spawnSync("open", [url], { encoding: "utf8" });
57
+ if (result.status === 0)
58
+ return true;
59
+ }
60
+ // Fallback: open the Privacy & Security root.
61
+ const fallback = spawnSync("open", ["x-apple.systempreferences:com.apple.preference.security"], { encoding: "utf8" });
62
+ return fallback.status === 0;
63
+ }
64
+ /**
65
+ * Name the app the human should toggle in Screen Recording settings.
66
+ * Prefer $TERM_PROGRAM (Ghostty, iTerm, vscode…) over the Swift runner process.
67
+ */
68
+ export function resolveEnableAppName(swiftHostApp) {
69
+ const term = process.env.TERM_PROGRAM?.trim();
70
+ if (term) {
71
+ const map = {
72
+ ghostty: "Ghostty",
73
+ "iTerm.app": "iTerm",
74
+ Apple_Terminal: "Terminal",
75
+ vscode: "Code", // VS Code / Cursor often still set vscode
76
+ WarpTerminal: "Warp",
77
+ WezTerm: "WezTerm",
78
+ Alacritty: "Alacritty",
79
+ };
80
+ return map[term] ?? term;
81
+ }
82
+ if (process.env.CURSOR_TRACE_ID || process.env.VSCODE_PID) {
83
+ return process.env.CURSOR_TRACE_ID ? "Cursor" : "Code";
84
+ }
85
+ if (swiftHostApp && !/swift/i.test(swiftHostApp))
86
+ return swiftHostApp;
87
+ return "your terminal or IDE (the app that launched this command)";
88
+ }
89
+ /**
90
+ * Detect Screen Recording TCC (best-effort).
91
+ *
92
+ * Uses CoreGraphics preflight via Swift. Note: the Swift process identity may
93
+ * differ from `screencapture`'s responsible app (your terminal). Capture
94
+ * failure remains authoritative; this steers the human to Settings early.
95
+ */
96
+ export function checkScreenRecordingAccess(options) {
97
+ assertMacOS();
98
+ const args = ["screen-access"];
99
+ if (options?.request)
100
+ args.push("--request");
101
+ const result = runWindowTools(args);
102
+ const text = result.stdout.trim();
103
+ if (!text) {
104
+ throw new Error(`screen-access returned no JSON (status ${result.status}): ${result.stderr.slice(0, 400)}`);
105
+ }
106
+ const parsed = JSON.parse(text);
107
+ const enableApp = resolveEnableAppName(parsed.hostApp);
108
+ return {
109
+ granted: Boolean(parsed.granted),
110
+ requested: Boolean(parsed.requested),
111
+ hostApp: parsed.hostApp || "unknown",
112
+ hostBundleId: parsed.hostBundleId ?? null,
113
+ enableApp,
114
+ settingsHint: `System Settings → Privacy & Security → Screen Recording → enable ${enableApp}, then quit & reopen it`,
115
+ };
116
+ }
117
+ export function formatScreenRecordingDeniedHelp(report) {
118
+ const app = report?.enableApp ?? resolveEnableAppName();
119
+ const lines = [
120
+ `Screen Recording permission is required for --source desktop.window.`,
121
+ ``,
122
+ `Fix:`,
123
+ ` 1. Open System Settings → Privacy & Security → Screen Recording`,
124
+ ` (or: astroshot movie open-screen-settings)`,
125
+ ` 2. Enable "${app}"`,
126
+ ` 3. Quit and reopen ${app} completely (TCC applies on next launch)`,
127
+ ` 4. Re-run: astroshot movie check-screen-access`,
128
+ ``,
129
+ `Note: macOS may not always show an automatic prompt; the Settings toggle is the reliable path.`,
130
+ `browser / pty sources do not need this permission.`,
131
+ ];
132
+ return lines.join("\n");
133
+ }
134
+ function throwScreenRecordingDenied(report) {
135
+ // Best-effort: open Settings so the human does not have to hunt.
136
+ try {
137
+ openScreenRecordingSettings();
138
+ }
139
+ catch {
140
+ /* ignore */
141
+ }
142
+ throw new Error(formatScreenRecordingDeniedHelp(report));
143
+ }
144
+ /**
145
+ * Preflight Screen Recording; optionally request + open Settings on deny.
146
+ * Call before desktop.window capture.
147
+ */
148
+ export function ensureScreenRecordingAccess(options) {
149
+ const report = checkScreenRecordingAccess({
150
+ request: options?.request ?? true,
151
+ });
152
+ if (report.granted)
153
+ return report;
154
+ if (options?.openSettings !== false) {
155
+ try {
156
+ openScreenRecordingSettings();
157
+ }
158
+ catch {
159
+ /* ignore */
160
+ }
161
+ }
162
+ throw new Error(formatScreenRecordingDeniedHelp(report));
163
+ }
164
+ /** List layer-0 windows as JSON via shipped Swift tool (interpreted by `swift`). */
165
+ export function listDesktopWindows() {
166
+ assertMacOS();
167
+ const result = runWindowTools(["list"]);
168
+ if (result.status !== 0) {
169
+ throw new Error(`Window list failed (${result.status}): ${result.stderr.slice(0, 500)}`);
170
+ }
171
+ const text = result.stdout.trim();
172
+ if (!text)
173
+ return [];
174
+ const parsed = JSON.parse(text);
175
+ return parsed.map((row) => ({
176
+ ...row,
177
+ bundleId: row.bundleId ?? null,
178
+ }));
179
+ }
180
+ export function matchDesktopWindow(windows, match) {
181
+ let candidates = windows;
182
+ if (match.windowId !== undefined) {
183
+ candidates = candidates.filter((w) => w.id === match.windowId);
184
+ }
185
+ if (match.bundleId) {
186
+ const want = match.bundleId.toLowerCase();
187
+ candidates = candidates.filter((w) => (w.bundleId ?? "").toLowerCase() === want);
188
+ }
189
+ if (match.owner) {
190
+ const want = match.owner.toLowerCase();
191
+ candidates = candidates.filter((w) => w.owner.toLowerCase().includes(want));
192
+ }
193
+ if (match.pid !== undefined) {
194
+ candidates = candidates.filter((w) => w.pid === match.pid);
195
+ }
196
+ if (match.titleRegex) {
197
+ const re = new RegExp(match.titleRegex, "i");
198
+ candidates = candidates.filter((w) => re.test(w.title));
199
+ }
200
+ if (candidates.length === 0) {
201
+ const sample = windows
202
+ .slice(0, 8)
203
+ .map((w) => ` id=${w.id} pid=${w.pid} bundle=${w.bundleId ?? "?"} owner=${JSON.stringify(w.owner)} title=${JSON.stringify(w.title)} ${w.width}x${w.height}`)
204
+ .join("\n");
205
+ throw new Error(`No desktop window matched ${JSON.stringify(match)}.\n` +
206
+ `Run: astroshot movie list-windows\n` +
207
+ `Sample windows:\n${sample || " (none)"}`);
208
+ }
209
+ if (match.pick === "first")
210
+ return candidates[0];
211
+ // Default largest (list is already size-sorted, but re-sort for safety).
212
+ return [...candidates].sort((a, b) => b.width * b.height - a.width * a.height)[0];
213
+ }
214
+ function captureWindowPng(windowId, outPath, cursor) {
215
+ const finalArgs = cursor
216
+ ? ["-x", "-C", "-o", "-t", "png", "-l", String(windowId), outPath]
217
+ : ["-x", "-o", "-t", "png", "-l", String(windowId), outPath];
218
+ const result = spawnSync(SCREENCAPTURE, finalArgs, { encoding: "utf8" });
219
+ if (result.status !== 0) {
220
+ let access;
221
+ try {
222
+ access = checkScreenRecordingAccess({ request: false });
223
+ }
224
+ catch {
225
+ /* ignore */
226
+ }
227
+ if (access && !access.granted) {
228
+ throwScreenRecordingDenied(access);
229
+ }
230
+ throw new Error(`screencapture failed for window ${windowId} (${result.status}): ` +
231
+ `${result.stderr || result.stdout || "unknown error"}.\n` +
232
+ formatScreenRecordingDeniedHelp(access));
233
+ }
234
+ if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 32) {
235
+ let access;
236
+ try {
237
+ access = checkScreenRecordingAccess({ request: false });
238
+ }
239
+ catch {
240
+ /* ignore */
241
+ }
242
+ if (access && !access.granted) {
243
+ throwScreenRecordingDenied(access);
244
+ }
245
+ throw new Error(`screencapture produced an empty image for window ${windowId}. ` +
246
+ "The window may have closed, or Screen Recording is denied.\n" +
247
+ formatScreenRecordingDeniedHelp(access));
248
+ }
249
+ }
250
+ function readPngSize(filePath) {
251
+ const fd = fs.openSync(filePath, "r");
252
+ try {
253
+ const buf = Buffer.alloc(24);
254
+ fs.readSync(fd, buf, 0, 24, 0);
255
+ if (buf.toString("ascii", 1, 4) !== "PNG")
256
+ return null;
257
+ return {
258
+ width: buf.readUInt32BE(16),
259
+ height: buf.readUInt32BE(20),
260
+ };
261
+ }
262
+ finally {
263
+ fs.closeSync(fd);
264
+ }
265
+ }
266
+ /**
267
+ * Sample a macOS window at `fps` for `durationMs`, encode to movie + poster.
268
+ * Uses OS `screencapture` (already on every Mac) — no separate download.
269
+ */
270
+ export async function recordDesktopWindowMovie(options) {
271
+ assertMacOS();
272
+ assertScreencapture();
273
+ const durationMs = options.durationMs ?? 3_000;
274
+ const fps = options.fps ?? 10;
275
+ if (!(durationMs > 0)) {
276
+ throw new Error("--duration-ms must be positive");
277
+ }
278
+ // Detect TCC up front (may prompt once; opens Settings if still denied).
279
+ ensureScreenRecordingAccess({ request: true, openSettings: true });
280
+ const windows = listDesktopWindows();
281
+ const target = matchDesktopWindow(windows, options.match);
282
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-desktop-"));
283
+ const probe = path.join(tmp, "probe.png");
284
+ try {
285
+ captureWindowPng(target.id, probe, Boolean(options.cursor));
286
+ }
287
+ catch (error) {
288
+ fs.rmSync(tmp, { recursive: true, force: true });
289
+ throw error;
290
+ }
291
+ const probed = readPngSize(probe);
292
+ const size = options.size ??
293
+ probed ?? {
294
+ width: target.width,
295
+ height: target.height,
296
+ };
297
+ const session = MovieSession.create({
298
+ ...options,
299
+ size,
300
+ fps,
301
+ source: "desktop.window",
302
+ description: options.description ??
303
+ `desktop.window id=${target.id} ${target.bundleId ?? target.owner} ${JSON.stringify(target.title)}`,
304
+ });
305
+ // Seed with probe frame so we never end empty if duration is tiny.
306
+ session.pushFrame(fs.readFileSync(probe));
307
+ const intervalMs = Math.max(50, Math.round(1000 / fps));
308
+ const deadline = Date.now() + durationMs;
309
+ let index = 1;
310
+ try {
311
+ while (Date.now() < deadline) {
312
+ const framePath = path.join(tmp, `f-${String(index).padStart(5, "0")}.png`);
313
+ const started = Date.now();
314
+ try {
315
+ captureWindowPng(target.id, framePath, Boolean(options.cursor));
316
+ session.pushFrameFile(framePath);
317
+ }
318
+ catch (error) {
319
+ // Window may close mid-recording; stop cleanly if we have frames.
320
+ if (session.listFrames().length > 0)
321
+ break;
322
+ throw error;
323
+ }
324
+ index += 1;
325
+ const elapsed = Date.now() - started;
326
+ const sleep = Math.max(0, intervalMs - elapsed);
327
+ if (sleep > 0 && Date.now() + sleep < deadline) {
328
+ await new Promise((r) => setTimeout(r, sleep));
329
+ }
330
+ }
331
+ return await session.stop({ status: options.status ?? "running" });
332
+ }
333
+ finally {
334
+ fs.rmSync(tmp, { recursive: true, force: true });
335
+ }
336
+ }
337
+ /** Resolve match flags from CLI-style strings. */
338
+ export function desktopMatchFromFlags(flags) {
339
+ const match = {};
340
+ if (flags["window-id"])
341
+ match.windowId = Number(flags["window-id"]);
342
+ if (flags["bundle-id"])
343
+ match.bundleId = flags["bundle-id"];
344
+ if (flags["title-regex"])
345
+ match.titleRegex = flags["title-regex"];
346
+ if (flags.owner)
347
+ match.owner = flags.owner;
348
+ if (flags.pid)
349
+ match.pid = Number(flags.pid);
350
+ if (flags.pick === "first" || flags.pick === "largest") {
351
+ match.pick = flags.pick;
352
+ }
353
+ if (match.windowId === undefined &&
354
+ !match.bundleId &&
355
+ !match.titleRegex &&
356
+ !match.owner &&
357
+ match.pid === undefined) {
358
+ throw new Error("desktop.window requires one of --window-id, --bundle-id, --title-regex, --owner, or --pid. " +
359
+ "Run: astroshot movie list-windows");
360
+ }
361
+ if (match.windowId !== undefined && Number.isNaN(match.windowId)) {
362
+ throw new Error("--window-id must be a number");
363
+ }
364
+ if (match.pid !== undefined && Number.isNaN(match.pid)) {
365
+ throw new Error("--pid must be a number");
366
+ }
367
+ return match;
368
+ }
369
+ /** Ensure Swift is runnable (for clearer errors at CLI start). */
370
+ export function assertDesktopToolchain() {
371
+ assertMacOS();
372
+ try {
373
+ execFileSync("swift", ["--version"], { encoding: "utf8", stdio: "pipe" });
374
+ }
375
+ catch {
376
+ throw new Error("desktop.window requires the Swift toolchain (`swift` on PATH). " +
377
+ "Install Xcode Command Line Tools: xcode-select --install");
378
+ }
379
+ assertScreencapture();
380
+ }
@@ -0,0 +1,19 @@
1
+ import type { MovieArtifact, MovieFormat, PersistedFrameSession, Size } from "../types.js";
2
+ /** Resolve the active frames session for a feature (latest if id omitted). */
3
+ export declare function loadFrameSession(root: string, feature: string, id?: string): PersistedFrameSession;
4
+ export declare function startFrameSession(options: {
5
+ feature: string;
6
+ slug: string;
7
+ root?: string;
8
+ runId?: string;
9
+ title?: string;
10
+ description?: string;
11
+ size?: Size;
12
+ fps?: number;
13
+ format?: MovieFormat;
14
+ }): PersistedFrameSession;
15
+ export declare function pushFrameToSession(state: PersistedFrameSession, imagePath: string): PersistedFrameSession;
16
+ export declare function markFrameSession(state: PersistedFrameSession, slug: string, note?: string): PersistedFrameSession;
17
+ export declare function stopFrameSession(state: PersistedFrameSession, options?: {
18
+ status?: "running" | "pass" | "fail" | "idle";
19
+ }): Promise<MovieArtifact>;
@@ -0,0 +1,165 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { encodeFrames, posterFromFrames } from "../encode.js";
5
+ import { assertKebabCase, assertSlug, defaultRunId, ensureDir, movieStateDir, resolveRoot, } from "../paths.js";
6
+ import { sinkMovie } from "../sink.js";
7
+ const STATE_FILE = "session.json";
8
+ function sessionDir(root, feature, id) {
9
+ return path.join(movieStateDir(root, feature), id);
10
+ }
11
+ function statePath(dir) {
12
+ return path.join(dir, STATE_FILE);
13
+ }
14
+ function writeState(state) {
15
+ const dir = sessionDir(state.root, state.feature, state.id);
16
+ ensureDir(dir);
17
+ ensureDir(state.frameDir);
18
+ const tmp = `${statePath(dir)}.tmp`;
19
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
20
+ fs.renameSync(tmp, statePath(dir));
21
+ }
22
+ function readState(dir) {
23
+ const raw = JSON.parse(fs.readFileSync(statePath(dir), "utf8"));
24
+ if (raw.version !== 1) {
25
+ throw new Error(`unsupported movie session version: ${raw.version}`);
26
+ }
27
+ return raw;
28
+ }
29
+ /** Resolve the active frames session for a feature (latest if id omitted). */
30
+ export function loadFrameSession(root, feature, id) {
31
+ const resolvedRoot = resolveRoot(root);
32
+ assertKebabCase(feature, "feature");
33
+ const base = movieStateDir(resolvedRoot, feature);
34
+ if (!fs.existsSync(base)) {
35
+ throw new Error(`no movie session for feature ${feature}`);
36
+ }
37
+ if (id) {
38
+ const dir = sessionDir(resolvedRoot, feature, id);
39
+ if (!fs.existsSync(statePath(dir))) {
40
+ throw new Error(`movie session not found: ${id}`);
41
+ }
42
+ return readState(dir);
43
+ }
44
+ const ids = fs
45
+ .readdirSync(base)
46
+ .filter((name) => fs.existsSync(statePath(path.join(base, name))))
47
+ .sort();
48
+ if (ids.length === 0) {
49
+ throw new Error(`no movie session for feature ${feature}`);
50
+ }
51
+ return readState(path.join(base, ids.at(-1)));
52
+ }
53
+ export function startFrameSession(options) {
54
+ assertKebabCase(options.feature, "feature");
55
+ assertSlug(options.slug);
56
+ const root = resolveRoot(options.root);
57
+ const id = randomBytes(6).toString("hex");
58
+ const dir = sessionDir(root, options.feature, id);
59
+ const frameDir = path.join(dir, "frames");
60
+ ensureDir(frameDir);
61
+ const state = {
62
+ version: 1,
63
+ id,
64
+ feature: options.feature,
65
+ slug: options.slug,
66
+ root,
67
+ runId: options.runId ?? defaultRunId(options.feature),
68
+ title: options.title,
69
+ description: options.description,
70
+ size: options.size ?? { width: 1280, height: 720 },
71
+ fps: options.fps ?? 15,
72
+ format: options.format ?? "webm",
73
+ source: "frames",
74
+ startedAtMs: Date.now(),
75
+ frameDir,
76
+ frameCount: 0,
77
+ chapters: [],
78
+ };
79
+ writeState(state);
80
+ return state;
81
+ }
82
+ export function pushFrameToSession(state, imagePath) {
83
+ if (!fs.existsSync(imagePath)) {
84
+ throw new Error(`frame not found: ${imagePath}`);
85
+ }
86
+ const ext = path.extname(imagePath).toLowerCase().replace(".", "") || "png";
87
+ if (ext !== "png" && ext !== "jpg" && ext !== "jpeg") {
88
+ throw new Error(`unsupported frame extension .${ext}`);
89
+ }
90
+ const index = String(state.frameCount).padStart(6, "0");
91
+ const dest = path.join(state.frameDir, `${index}.${ext}`);
92
+ fs.copyFileSync(imagePath, dest);
93
+ const next = {
94
+ ...state,
95
+ frameCount: state.frameCount + 1,
96
+ };
97
+ writeState(next);
98
+ return next;
99
+ }
100
+ export function markFrameSession(state, slug, note) {
101
+ assertSlug(slug);
102
+ const chapter = {
103
+ slug,
104
+ tMs: Date.now() - state.startedAtMs,
105
+ note,
106
+ };
107
+ const next = {
108
+ ...state,
109
+ chapters: [...state.chapters, chapter],
110
+ };
111
+ writeState(next);
112
+ return next;
113
+ }
114
+ export async function stopFrameSession(state, options) {
115
+ const frames = fs
116
+ .readdirSync(state.frameDir)
117
+ .filter((name) => /\.(png|jpe?g)$/i.test(name))
118
+ .sort()
119
+ .map((name) => path.join(state.frameDir, name));
120
+ if (frames.length === 0) {
121
+ throw new Error("cannot stop movie session with zero frames");
122
+ }
123
+ const work = path.join(sessionDir(state.root, state.feature, state.id), "out");
124
+ ensureDir(work);
125
+ const outExt = state.format === "mp4" ? ".mp4" : ".webm";
126
+ const encoded = await encodeFrames({
127
+ framePaths: frames,
128
+ outPath: path.join(work, `movie${outExt}`),
129
+ size: state.size,
130
+ fps: state.fps,
131
+ });
132
+ const posterPath = posterFromFrames(frames, path.join(work, "poster.png"));
133
+ const durationMs = encoded.durationMs;
134
+ const published = sinkMovie({
135
+ root: state.root,
136
+ feature: state.feature,
137
+ slug: state.slug,
138
+ runId: state.runId,
139
+ title: state.title,
140
+ description: state.description,
141
+ status: options?.status ?? "running",
142
+ source: "frames",
143
+ posterPath,
144
+ videoPath: encoded.videoPath,
145
+ durationMs,
146
+ chapters: state.chapters,
147
+ size: state.size,
148
+ });
149
+ // Drop session state; artifacts live in .astroshot/.
150
+ fs.rmSync(sessionDir(state.root, state.feature, state.id), {
151
+ recursive: true,
152
+ force: true,
153
+ });
154
+ return {
155
+ videoPath: published.videoDest,
156
+ posterPath: published.posterDest,
157
+ durationMs,
158
+ chapters: state.chapters,
159
+ source: "frames",
160
+ feature: state.feature,
161
+ slug: state.slug,
162
+ sequence: published.sequence,
163
+ runId: state.runId,
164
+ };
165
+ }
@@ -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>;