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.
Files changed (119) hide show
  1. package/README.md +87 -0
  2. package/bin/astroshot.mjs +71 -0
  3. package/node_modules/@archastro/astroshot/LICENSE +21 -0
  4. package/node_modules/@archastro/astroshot/README.md +96 -0
  5. package/node_modules/@archastro/astroshot/bin/astroshot.mjs +276 -0
  6. package/node_modules/@archastro/astroshot/bin/demo.mjs +275 -0
  7. package/node_modules/@archastro/astroshot/bin/doctor.mjs +492 -0
  8. package/node_modules/@archastro/astroshot/bin/mac-preferences.mjs +346 -0
  9. package/node_modules/@archastro/astroshot/bin/templates.mjs +186 -0
  10. package/node_modules/@archastro/astroshot/fixtures/demo/fixtures.json +42 -0
  11. package/node_modules/@archastro/astroshot/fixtures/demo/journey.png +0 -0
  12. package/node_modules/@archastro/astroshot/fixtures/demo/journey.webm +0 -0
  13. package/node_modules/@archastro/astroshot/fixtures/demo/next-steps.png +0 -0
  14. package/node_modules/@archastro/astroshot/fixtures/demo/welcome.png +0 -0
  15. package/node_modules/@archastro/astroshot/ink.d.ts +5 -0
  16. package/node_modules/@archastro/astroshot/ink.js +1 -0
  17. package/node_modules/@archastro/astroshot/package.json +79 -0
  18. package/node_modules/@archastro/astroshot/pty.d.ts +8 -0
  19. package/node_modules/@archastro/astroshot/pty.js +1 -0
  20. package/node_modules/@archastro/astroshot/react.d.ts +1 -0
  21. package/node_modules/@archastro/astroshot/react.js +1 -0
  22. package/node_modules/@archastro/astroshot/tui.d.ts +1 -0
  23. package/node_modules/@archastro/astroshot/tui.js +1 -0
  24. package/node_modules/@archastro/movie-harness/README.md +138 -0
  25. package/node_modules/@archastro/movie-harness/bin/astroshot-movie.mjs +5 -0
  26. package/node_modules/@archastro/movie-harness/dist/cli.d.ts +1 -0
  27. package/node_modules/@archastro/movie-harness/dist/cli.js +389 -0
  28. package/node_modules/@archastro/movie-harness/dist/encode.d.ts +14 -0
  29. package/node_modules/@archastro/movie-harness/dist/encode.js +184 -0
  30. package/node_modules/@archastro/movie-harness/dist/index.d.ts +14 -0
  31. package/node_modules/@archastro/movie-harness/dist/index.js +11 -0
  32. package/node_modules/@archastro/movie-harness/dist/paths.d.ts +9 -0
  33. package/node_modules/@archastro/movie-harness/dist/paths.js +57 -0
  34. package/node_modules/@archastro/movie-harness/dist/png.d.ts +8 -0
  35. package/node_modules/@archastro/movie-harness/dist/png.js +79 -0
  36. package/node_modules/@archastro/movie-harness/dist/session.d.ts +45 -0
  37. package/node_modules/@archastro/movie-harness/dist/session.js +170 -0
  38. package/node_modules/@archastro/movie-harness/dist/sink.d.ts +7 -0
  39. package/node_modules/@archastro/movie-harness/dist/sink.js +113 -0
  40. package/node_modules/@archastro/movie-harness/dist/source-help.d.ts +29 -0
  41. package/node_modules/@archastro/movie-harness/dist/source-help.js +222 -0
  42. package/node_modules/@archastro/movie-harness/dist/sources/browser.d.ts +7 -0
  43. package/node_modules/@archastro/movie-harness/dist/sources/browser.js +87 -0
  44. package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.d.ts +110 -0
  45. package/node_modules/@archastro/movie-harness/dist/sources/desktop-macos.js +505 -0
  46. package/node_modules/@archastro/movie-harness/dist/sources/frames-store.d.ts +19 -0
  47. package/node_modules/@archastro/movie-harness/dist/sources/frames-store.js +165 -0
  48. package/node_modules/@archastro/movie-harness/dist/sources/pty.d.ts +15 -0
  49. package/node_modules/@archastro/movie-harness/dist/sources/pty.js +330 -0
  50. package/node_modules/@archastro/movie-harness/dist/terminal-paint.d.ts +27 -0
  51. package/node_modules/@archastro/movie-harness/dist/terminal-paint.js +190 -0
  52. package/node_modules/@archastro/movie-harness/dist/types.d.ts +140 -0
  53. package/node_modules/@archastro/movie-harness/dist/types.js +1 -0
  54. package/node_modules/@archastro/movie-harness/native/macos/WindowTools.swift +164 -0
  55. package/node_modules/@archastro/movie-harness/package.json +67 -0
  56. package/node_modules/@archastro/react-shot/LICENSE +21 -0
  57. package/node_modules/@archastro/react-shot/README.md +181 -0
  58. package/node_modules/@archastro/react-shot/bin/react-shot.mjs +2 -0
  59. package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts +4 -0
  60. package/node_modules/@archastro/react-shot/dist/batch-paths.d.ts.map +1 -0
  61. package/node_modules/@archastro/react-shot/dist/batch-paths.js +34 -0
  62. package/node_modules/@archastro/react-shot/dist/batch-paths.js.map +1 -0
  63. package/node_modules/@archastro/react-shot/dist/cli.d.ts +3 -0
  64. package/node_modules/@archastro/react-shot/dist/cli.d.ts.map +1 -0
  65. package/node_modules/@archastro/react-shot/dist/cli.js +217 -0
  66. package/node_modules/@archastro/react-shot/dist/cli.js.map +1 -0
  67. package/node_modules/@archastro/react-shot/dist/config.d.ts +5 -0
  68. package/node_modules/@archastro/react-shot/dist/config.d.ts.map +1 -0
  69. package/node_modules/@archastro/react-shot/dist/config.js +75 -0
  70. package/node_modules/@archastro/react-shot/dist/config.js.map +1 -0
  71. package/node_modules/@archastro/react-shot/dist/create-server.d.ts +13 -0
  72. package/node_modules/@archastro/react-shot/dist/create-server.d.ts.map +1 -0
  73. package/node_modules/@archastro/react-shot/dist/create-server.js +254 -0
  74. package/node_modules/@archastro/react-shot/dist/create-server.js.map +1 -0
  75. package/node_modules/@archastro/react-shot/dist/index.d.ts +4 -0
  76. package/node_modules/@archastro/react-shot/dist/index.d.ts.map +1 -0
  77. package/node_modules/@archastro/react-shot/dist/index.js +3 -0
  78. package/node_modules/@archastro/react-shot/dist/index.js.map +1 -0
  79. package/node_modules/@archastro/react-shot/dist/meta.d.ts +9 -0
  80. package/node_modules/@archastro/react-shot/dist/meta.d.ts.map +1 -0
  81. package/node_modules/@archastro/react-shot/dist/meta.js +23 -0
  82. package/node_modules/@archastro/react-shot/dist/meta.js.map +1 -0
  83. package/node_modules/@archastro/react-shot/dist/shot.d.ts +4 -0
  84. package/node_modules/@archastro/react-shot/dist/shot.d.ts.map +1 -0
  85. package/node_modules/@archastro/react-shot/dist/shot.js +259 -0
  86. package/node_modules/@archastro/react-shot/dist/shot.js.map +1 -0
  87. package/node_modules/@archastro/react-shot/dist/stubs.d.ts +6 -0
  88. package/node_modules/@archastro/react-shot/dist/stubs.d.ts.map +1 -0
  89. package/node_modules/@archastro/react-shot/dist/stubs.js +49 -0
  90. package/node_modules/@archastro/react-shot/dist/stubs.js.map +1 -0
  91. package/node_modules/@archastro/react-shot/dist/types.d.ts +79 -0
  92. package/node_modules/@archastro/react-shot/dist/types.d.ts.map +1 -0
  93. package/node_modules/@archastro/react-shot/dist/types.js +2 -0
  94. package/node_modules/@archastro/react-shot/dist/types.js.map +1 -0
  95. package/node_modules/@archastro/react-shot/host/index.html +22 -0
  96. package/node_modules/@archastro/react-shot/package.json +81 -0
  97. package/node_modules/@archastro/tui-shot/LICENSE +21 -0
  98. package/node_modules/@archastro/tui-shot/README.md +145 -0
  99. package/node_modules/@archastro/tui-shot/bin/tui-shot.mjs +23 -0
  100. package/node_modules/@archastro/tui-shot/dist/batch-paths.d.ts +3 -0
  101. package/node_modules/@archastro/tui-shot/dist/batch-paths.js +33 -0
  102. package/node_modules/@archastro/tui-shot/dist/cli.d.ts +2 -0
  103. package/node_modules/@archastro/tui-shot/dist/cli.js +224 -0
  104. package/node_modules/@archastro/tui-shot/dist/index.d.ts +3 -0
  105. package/node_modules/@archastro/tui-shot/dist/index.js +2 -0
  106. package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.d.ts +1 -0
  107. package/node_modules/@archastro/tui-shot/dist/pty-exit-wrapper.js +50 -0
  108. package/node_modules/@archastro/tui-shot/dist/pty-shot.d.ts +2 -0
  109. package/node_modules/@archastro/tui-shot/dist/pty-shot.js +483 -0
  110. package/node_modules/@archastro/tui-shot/dist/render-ink.d.ts +10 -0
  111. package/node_modules/@archastro/tui-shot/dist/render-ink.js +87 -0
  112. package/node_modules/@archastro/tui-shot/dist/shot.d.ts +24 -0
  113. package/node_modules/@archastro/tui-shot/dist/shot.js +250 -0
  114. package/node_modules/@archastro/tui-shot/dist/terminal-html.d.ts +14 -0
  115. package/node_modules/@archastro/tui-shot/dist/terminal-html.js +144 -0
  116. package/node_modules/@archastro/tui-shot/dist/types.d.ts +81 -0
  117. package/node_modules/@archastro/tui-shot/dist/types.js +1 -0
  118. package/node_modules/@archastro/tui-shot/package.json +90 -0
  119. package/package.json +68 -0
@@ -0,0 +1,505 @@
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 zlib from "node:zlib";
7
+ import { MovieSession } from "../session.js";
8
+ const SCREENCAPTURE = "/usr/sbin/screencapture";
9
+ /** Known deep-links to the Screen Recording privacy pane (varies by macOS). */
10
+ const SCREEN_RECORDING_SETTINGS_URLS = [
11
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture",
12
+ "x-apple.systempreferences:com.apple.settings.PrivacySecurity.extension?Privacy_ScreenCapture",
13
+ ];
14
+ function packageNativeSwift() {
15
+ const here = path.dirname(fileURLToPath(import.meta.url));
16
+ // dist/sources -> ../../native/macos
17
+ return path.resolve(here, "../../native/macos/WindowTools.swift");
18
+ }
19
+ function assertMacOS() {
20
+ if (process.platform !== "darwin") {
21
+ throw new Error("desktop.window is only implemented on macOS (CGWindowList + screencapture). " +
22
+ "On other platforms use --source frames and push your own captures. " +
23
+ "See: astroshot movie which-source");
24
+ }
25
+ }
26
+ function assertScreencapture() {
27
+ if (!fs.existsSync(SCREENCAPTURE)) {
28
+ throw new Error(`screencapture not found at ${SCREENCAPTURE}`);
29
+ }
30
+ }
31
+ function runWindowTools(args) {
32
+ const script = packageNativeSwift();
33
+ if (!fs.existsSync(script)) {
34
+ throw new Error(`WindowTools.swift missing at ${script}. Reinstall @archastro/movie-harness.`);
35
+ }
36
+ const result = spawnSync("swift", [script, ...args], {
37
+ encoding: "utf8",
38
+ maxBuffer: 8 * 1024 * 1024,
39
+ });
40
+ if (result.error) {
41
+ throw new Error(`Could not run Swift window tools (${result.error.message}). ` +
42
+ "Install Xcode Command Line Tools (`xcode-select --install`).");
43
+ }
44
+ return {
45
+ status: result.status,
46
+ stdout: result.stdout ?? "",
47
+ stderr: result.stderr ?? "",
48
+ };
49
+ }
50
+ /**
51
+ * Open System Settings to the Screen Recording privacy list.
52
+ * Best-effort: URL schemes differ slightly across macOS versions.
53
+ */
54
+ export function openScreenRecordingSettings() {
55
+ assertMacOS();
56
+ for (const url of SCREEN_RECORDING_SETTINGS_URLS) {
57
+ const result = spawnSync("open", [url], { encoding: "utf8" });
58
+ if (result.status === 0)
59
+ return true;
60
+ }
61
+ // Fallback: open the Privacy & Security root.
62
+ const fallback = spawnSync("open", ["x-apple.systempreferences:com.apple.preference.security"], { encoding: "utf8" });
63
+ return fallback.status === 0;
64
+ }
65
+ /**
66
+ * Name the app the human should toggle in Screen Recording settings.
67
+ * Prefer $TERM_PROGRAM (Ghostty, iTerm, vscode…) over the Swift runner process.
68
+ */
69
+ export function resolveEnableAppName(swiftHostApp) {
70
+ const term = process.env.TERM_PROGRAM?.trim();
71
+ if (term) {
72
+ const map = {
73
+ ghostty: "Ghostty",
74
+ "iTerm.app": "iTerm",
75
+ Apple_Terminal: "Terminal",
76
+ vscode: "Code", // VS Code / Cursor often still set vscode
77
+ WarpTerminal: "Warp",
78
+ WezTerm: "WezTerm",
79
+ Alacritty: "Alacritty",
80
+ };
81
+ return map[term] ?? term;
82
+ }
83
+ if (process.env.CURSOR_TRACE_ID || process.env.VSCODE_PID) {
84
+ return process.env.CURSOR_TRACE_ID ? "Cursor" : "Code";
85
+ }
86
+ if (swiftHostApp && !/swift/i.test(swiftHostApp))
87
+ return swiftHostApp;
88
+ return "your terminal or IDE (the app that launched this command)";
89
+ }
90
+ /**
91
+ * Detect Screen Recording TCC (best-effort).
92
+ *
93
+ * Uses CoreGraphics preflight via Swift. Note: the Swift process identity may
94
+ * differ from `screencapture`'s responsible app (your terminal). Capture
95
+ * failure remains authoritative; this steers the human to Settings early.
96
+ */
97
+ export function checkScreenRecordingAccess(options) {
98
+ assertMacOS();
99
+ const args = ["screen-access"];
100
+ if (options?.request)
101
+ args.push("--request");
102
+ const result = runWindowTools(args);
103
+ const text = result.stdout.trim();
104
+ if (!text) {
105
+ throw new Error(`screen-access returned no JSON (status ${result.status}): ${result.stderr.slice(0, 400)}`);
106
+ }
107
+ const parsed = JSON.parse(text);
108
+ const enableApp = resolveEnableAppName(parsed.hostApp);
109
+ return {
110
+ granted: Boolean(parsed.granted),
111
+ requested: Boolean(parsed.requested),
112
+ hostApp: parsed.hostApp || "unknown",
113
+ hostBundleId: parsed.hostBundleId ?? null,
114
+ enableApp,
115
+ settingsHint: `System Settings → Privacy & Security → Screen Recording → enable ${enableApp}, then quit & reopen it`,
116
+ };
117
+ }
118
+ export function formatScreenRecordingDeniedHelp(report) {
119
+ const app = report?.enableApp ?? resolveEnableAppName();
120
+ const lines = [
121
+ `Screen Recording permission is required for --source desktop.window.`,
122
+ ``,
123
+ `Fix:`,
124
+ ` 1. Open System Settings → Privacy & Security → Screen Recording`,
125
+ ` (or: astroshot movie open-screen-settings)`,
126
+ ` 2. Enable "${app}"`,
127
+ ` 3. Quit and reopen ${app} completely (TCC applies on next launch)`,
128
+ ` 4. Re-run: astroshot movie check-screen-access`,
129
+ ``,
130
+ `Note: macOS may not always show an automatic prompt; the Settings toggle is the reliable path.`,
131
+ `browser / pty sources do not need this permission.`,
132
+ ];
133
+ return lines.join("\n");
134
+ }
135
+ function throwScreenRecordingDenied(report) {
136
+ // Best-effort: open Settings so the human does not have to hunt.
137
+ try {
138
+ openScreenRecordingSettings();
139
+ }
140
+ catch {
141
+ /* ignore */
142
+ }
143
+ throw new Error(formatScreenRecordingDeniedHelp(report));
144
+ }
145
+ /**
146
+ * Preflight Screen Recording; optionally request + open Settings on deny.
147
+ * Call before desktop.window capture.
148
+ */
149
+ export function ensureScreenRecordingAccess(options) {
150
+ const report = checkScreenRecordingAccess({
151
+ request: options?.request ?? true,
152
+ });
153
+ if (report.granted)
154
+ return report;
155
+ if (options?.openSettings !== false) {
156
+ try {
157
+ openScreenRecordingSettings();
158
+ }
159
+ catch {
160
+ /* ignore */
161
+ }
162
+ }
163
+ throw new Error(formatScreenRecordingDeniedHelp(report));
164
+ }
165
+ /** List layer-0 windows as JSON via shipped Swift tool (interpreted by `swift`). */
166
+ export function listDesktopWindows() {
167
+ assertMacOS();
168
+ const result = runWindowTools(["list"]);
169
+ if (result.status !== 0) {
170
+ throw new Error(`Window list failed (${result.status}): ${result.stderr.slice(0, 500)}`);
171
+ }
172
+ const text = result.stdout.trim();
173
+ if (!text)
174
+ return [];
175
+ const parsed = JSON.parse(text);
176
+ return parsed.map((row) => ({
177
+ ...row,
178
+ bundleId: row.bundleId ?? null,
179
+ }));
180
+ }
181
+ export function matchDesktopWindow(windows, match) {
182
+ let candidates = windows;
183
+ if (match.windowId !== undefined) {
184
+ candidates = candidates.filter((w) => w.id === match.windowId);
185
+ }
186
+ if (match.bundleId) {
187
+ const want = match.bundleId.toLowerCase();
188
+ candidates = candidates.filter((w) => (w.bundleId ?? "").toLowerCase() === want);
189
+ }
190
+ if (match.owner) {
191
+ const want = match.owner.toLowerCase();
192
+ candidates = candidates.filter((w) => w.owner.toLowerCase().includes(want));
193
+ }
194
+ if (match.pid !== undefined) {
195
+ candidates = candidates.filter((w) => w.pid === match.pid);
196
+ }
197
+ if (match.titleRegex) {
198
+ const re = new RegExp(match.titleRegex, "i");
199
+ candidates = candidates.filter((w) => re.test(w.title));
200
+ }
201
+ if (candidates.length === 0) {
202
+ const sample = windows
203
+ .slice(0, 8)
204
+ .map((w) => ` id=${w.id} pid=${w.pid} layer=${w.layer ?? "?"} bundle=${w.bundleId ?? "?"} owner=${JSON.stringify(w.owner)} title=${JSON.stringify(w.title)} ${w.width}x${w.height} onScreen=${w.onScreen}`)
205
+ .join("\n");
206
+ throw new Error(`No desktop window matched ${JSON.stringify(match)}.\n` +
207
+ `Run: astroshot movie list-windows\n` +
208
+ `Sample windows:\n${sample || " (none)"}`);
209
+ }
210
+ const preferOnScreen = match.preferOnScreen !== false;
211
+ if (preferOnScreen && match.windowId === undefined) {
212
+ const onScreen = candidates.filter((w) => w.onScreen);
213
+ if (onScreen.length > 0)
214
+ candidates = onScreen;
215
+ }
216
+ if (match.pick === "first")
217
+ return candidates[0];
218
+ // Default: largest area, then lower layer (normal windows over floaters).
219
+ return [...candidates].sort((a, b) => {
220
+ const area = b.width * b.height - a.width * a.height;
221
+ if (area !== 0)
222
+ return area;
223
+ return (a.layer ?? 0) - (b.layer ?? 0);
224
+ })[0];
225
+ }
226
+ /** Human-readable manifest description for a captured window. */
227
+ export function describeDesktopWindow(target) {
228
+ const name = target.bundleId?.split(".").pop() || target.owner || "window";
229
+ const title = target.title.trim();
230
+ const size = `${target.width}×${target.height}`;
231
+ const where = target.onScreen ? "on-screen" : "off-screen";
232
+ const layer = target.layer !== undefined && target.layer > 0
233
+ ? `, layer ${target.layer}`
234
+ : "";
235
+ if (title) {
236
+ return `${name}: “${title}” (${size}, ${where}${layer})`;
237
+ }
238
+ return `${name} window (${size}, ${where}${layer})`;
239
+ }
240
+ /**
241
+ * True when a PNG is almost entirely very dark (typical failed/off-screen capture).
242
+ * Samples up to ~4k pixels across a simple grid; supports 8-bit RGB/RGBA.
243
+ */
244
+ export function isNearlyBlankPng(filePath, options) {
245
+ const maxMeanLuma = options?.maxMeanLuma ?? 12;
246
+ const minDarkFraction = options?.minDarkFraction ?? 0.97;
247
+ let data;
248
+ try {
249
+ data = fs.readFileSync(filePath);
250
+ }
251
+ catch {
252
+ return true;
253
+ }
254
+ if (data.length < 33 || data.toString("ascii", 1, 4) !== "PNG")
255
+ return true;
256
+ // Minimal PNG scan: find IDAT chunks, inflate, average luma on a grid.
257
+ const width = data.readUInt32BE(16);
258
+ const height = data.readUInt32BE(20);
259
+ const bitDepth = data[24];
260
+ const colorType = data[25];
261
+ if (!width || !height || bitDepth !== 8)
262
+ return false;
263
+ // 2 = RGB, 6 = RGBA
264
+ if (colorType !== 2 && colorType !== 6)
265
+ return false;
266
+ const channels = colorType === 6 ? 4 : 3;
267
+ const idatParts = [];
268
+ let offset = 8;
269
+ while (offset + 8 <= data.length) {
270
+ const len = data.readUInt32BE(offset);
271
+ const type = data.toString("ascii", offset + 4, offset + 8);
272
+ const start = offset + 8;
273
+ const end = start + len;
274
+ if (end + 4 > data.length)
275
+ break;
276
+ if (type === "IDAT")
277
+ idatParts.push(data.subarray(start, end));
278
+ if (type === "IEND")
279
+ break;
280
+ offset = end + 4;
281
+ }
282
+ if (idatParts.length === 0)
283
+ return true;
284
+ let inflated;
285
+ try {
286
+ inflated = zlib.inflateSync(Buffer.concat(idatParts));
287
+ }
288
+ catch {
289
+ return false; // can't decode — don't claim blank
290
+ }
291
+ const stride = 1 + width * channels; // filter byte + row
292
+ const expected = stride * height;
293
+ if (inflated.length < expected)
294
+ return false;
295
+ // Only sample rows that use filter type 0 (None) for correct RGB bytes.
296
+ // For filtered rows, still sample raw bytes as a coarse darkness heuristic.
297
+ let samples = 0;
298
+ let dark = 0;
299
+ let lumaSum = 0;
300
+ const stepY = Math.max(1, Math.floor(height / 32));
301
+ const stepX = Math.max(1, Math.floor(width / 32));
302
+ for (let y = 0; y < height; y += stepY) {
303
+ const rowStart = y * stride;
304
+ const filter = inflated[rowStart] ?? 0;
305
+ for (let x = 0; x < width; x += stepX) {
306
+ const i = rowStart + 1 + x * channels;
307
+ const r = inflated[i] ?? 0;
308
+ const g = inflated[i + 1] ?? 0;
309
+ const b = inflated[i + 2] ?? 0;
310
+ // filter≠0 means bytes aren't raw RGB; still treat very low triples as dark.
311
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
312
+ lumaSum += luma;
313
+ if (luma <= maxMeanLuma)
314
+ dark += 1;
315
+ samples += 1;
316
+ void filter;
317
+ }
318
+ }
319
+ if (samples === 0)
320
+ return true;
321
+ const mean = lumaSum / samples;
322
+ const darkFraction = dark / samples;
323
+ return mean <= maxMeanLuma && darkFraction >= minDarkFraction;
324
+ }
325
+ function captureWindowPng(windowId, outPath, cursor) {
326
+ const finalArgs = cursor
327
+ ? ["-x", "-C", "-o", "-t", "png", "-l", String(windowId), outPath]
328
+ : ["-x", "-o", "-t", "png", "-l", String(windowId), outPath];
329
+ const result = spawnSync(SCREENCAPTURE, finalArgs, { encoding: "utf8" });
330
+ if (result.status !== 0) {
331
+ let access;
332
+ try {
333
+ access = checkScreenRecordingAccess({ request: false });
334
+ }
335
+ catch {
336
+ /* ignore */
337
+ }
338
+ if (access && !access.granted) {
339
+ throwScreenRecordingDenied(access);
340
+ }
341
+ throw new Error(`screencapture failed for window ${windowId} (${result.status}): ` +
342
+ `${result.stderr || result.stdout || "unknown error"}.\n` +
343
+ formatScreenRecordingDeniedHelp(access));
344
+ }
345
+ if (!fs.existsSync(outPath) || fs.statSync(outPath).size < 32) {
346
+ let access;
347
+ try {
348
+ access = checkScreenRecordingAccess({ request: false });
349
+ }
350
+ catch {
351
+ /* ignore */
352
+ }
353
+ if (access && !access.granted) {
354
+ throwScreenRecordingDenied(access);
355
+ }
356
+ throw new Error(`screencapture produced an empty image for window ${windowId}. ` +
357
+ "The window may have closed, or Screen Recording is denied.\n" +
358
+ formatScreenRecordingDeniedHelp(access));
359
+ }
360
+ }
361
+ function readPngSize(filePath) {
362
+ const fd = fs.openSync(filePath, "r");
363
+ try {
364
+ const buf = Buffer.alloc(24);
365
+ fs.readSync(fd, buf, 0, 24, 0);
366
+ if (buf.toString("ascii", 1, 4) !== "PNG")
367
+ return null;
368
+ return {
369
+ width: buf.readUInt32BE(16),
370
+ height: buf.readUInt32BE(20),
371
+ };
372
+ }
373
+ finally {
374
+ fs.closeSync(fd);
375
+ }
376
+ }
377
+ /**
378
+ * Sample a macOS window at `fps` for `durationMs`, encode to movie + poster.
379
+ * Uses OS `screencapture` (already on every Mac) — no separate download.
380
+ */
381
+ export async function recordDesktopWindowMovie(options) {
382
+ assertMacOS();
383
+ assertScreencapture();
384
+ const durationMs = options.durationMs ?? 3_000;
385
+ const fps = options.fps ?? 10;
386
+ if (!(durationMs > 0)) {
387
+ throw new Error("--duration-ms must be positive");
388
+ }
389
+ // Detect TCC up front (may prompt once; opens Settings if still denied).
390
+ ensureScreenRecordingAccess({ request: true, openSettings: true });
391
+ const windows = listDesktopWindows();
392
+ const target = matchDesktopWindow(windows, options.match);
393
+ if (!target.onScreen && !options.allowBlank) {
394
+ throw new Error(`Matched window id=${target.id} (${target.bundleId ?? target.owner}) is off-screen ` +
395
+ `(${target.width}×${target.height} at ${target.x},${target.y}). ` +
396
+ "Off-screen windows usually produce black movies. Bring the window on-screen " +
397
+ "(open the tray/popover) or pass --allow-blank to record anyway.\n" +
398
+ `Hint: ${describeDesktopWindow(target)}`);
399
+ }
400
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-desktop-"));
401
+ const probe = path.join(tmp, "probe.png");
402
+ try {
403
+ captureWindowPng(target.id, probe, Boolean(options.cursor));
404
+ }
405
+ catch (error) {
406
+ fs.rmSync(tmp, { recursive: true, force: true });
407
+ throw error;
408
+ }
409
+ if (!options.allowBlank && isNearlyBlankPng(probe)) {
410
+ fs.rmSync(tmp, { recursive: true, force: true });
411
+ throw new Error(`Probe frame for window id=${target.id} is nearly blank/black. ` +
412
+ "Screen Recording may be denied for this host app, the window may be empty, " +
413
+ "or the popover may not be visible. Fix visibility/TCC, or pass --allow-blank.\n" +
414
+ `Window: ${describeDesktopWindow(target)}\n` +
415
+ "Run: astroshot movie check-screen-access");
416
+ }
417
+ const probed = readPngSize(probe);
418
+ const size = options.size ??
419
+ probed ?? {
420
+ width: target.width,
421
+ height: target.height,
422
+ };
423
+ const session = MovieSession.create({
424
+ ...options,
425
+ size,
426
+ fps,
427
+ source: "desktop.window",
428
+ description: options.description ?? describeDesktopWindow(target),
429
+ });
430
+ // Seed with probe frame so we never end empty if duration is tiny.
431
+ session.pushFrame(fs.readFileSync(probe));
432
+ const intervalMs = Math.max(50, Math.round(1000 / fps));
433
+ const deadline = Date.now() + durationMs;
434
+ let index = 1;
435
+ try {
436
+ while (Date.now() < deadline) {
437
+ const framePath = path.join(tmp, `f-${String(index).padStart(5, "0")}.png`);
438
+ const started = Date.now();
439
+ try {
440
+ captureWindowPng(target.id, framePath, Boolean(options.cursor));
441
+ session.pushFrameFile(framePath);
442
+ }
443
+ catch (error) {
444
+ // Window may close mid-recording; stop cleanly if we have frames.
445
+ if (session.listFrames().length > 0)
446
+ break;
447
+ throw error;
448
+ }
449
+ index += 1;
450
+ const elapsed = Date.now() - started;
451
+ const sleep = Math.max(0, intervalMs - elapsed);
452
+ if (sleep > 0 && Date.now() + sleep < deadline) {
453
+ await new Promise((r) => setTimeout(r, sleep));
454
+ }
455
+ }
456
+ return await session.stop({ status: options.status ?? "running" });
457
+ }
458
+ finally {
459
+ fs.rmSync(tmp, { recursive: true, force: true });
460
+ }
461
+ }
462
+ /** Resolve match flags from CLI-style strings. */
463
+ export function desktopMatchFromFlags(flags) {
464
+ const match = {};
465
+ if (flags["window-id"])
466
+ match.windowId = Number(flags["window-id"]);
467
+ if (flags["bundle-id"])
468
+ match.bundleId = flags["bundle-id"];
469
+ if (flags["title-regex"])
470
+ match.titleRegex = flags["title-regex"];
471
+ if (flags.owner)
472
+ match.owner = flags.owner;
473
+ if (flags.pid)
474
+ match.pid = Number(flags.pid);
475
+ if (flags.pick === "first" || flags.pick === "largest") {
476
+ match.pick = flags.pick;
477
+ }
478
+ if (match.windowId === undefined &&
479
+ !match.bundleId &&
480
+ !match.titleRegex &&
481
+ !match.owner &&
482
+ match.pid === undefined) {
483
+ throw new Error("desktop.window requires one of --window-id, --bundle-id, --title-regex, --owner, or --pid. " +
484
+ "Run: astroshot movie list-windows");
485
+ }
486
+ if (match.windowId !== undefined && Number.isNaN(match.windowId)) {
487
+ throw new Error("--window-id must be a number");
488
+ }
489
+ if (match.pid !== undefined && Number.isNaN(match.pid)) {
490
+ throw new Error("--pid must be a number");
491
+ }
492
+ return match;
493
+ }
494
+ /** Ensure Swift is runnable (for clearer errors at CLI start). */
495
+ export function assertDesktopToolchain() {
496
+ assertMacOS();
497
+ try {
498
+ execFileSync("swift", ["--version"], { encoding: "utf8", stdio: "pipe" });
499
+ }
500
+ catch {
501
+ throw new Error("desktop.window requires the Swift toolchain (`swift` on PATH). " +
502
+ "Install Xcode Command Line Tools: xcode-select --install");
503
+ }
504
+ assertScreencapture();
505
+ }
@@ -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
+ }