@archastro/movie-harness 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -30,7 +30,7 @@ run --source …
30
30
  pty --fixture path.yaml|json # truecolor TUI — preferred for terminals
31
31
  pty-demo [--color 7c5cff] # truecolor smoke, no program
32
32
  desktop.window --bundle-id ID | --window-id N | --title-regex RE | --owner NAME | --pid N
33
- [--duration-ms 3000] [--fps 10] [--cursor]
33
+ [--duration-ms 3000] [--fps 10] [--cursor] [--allow-blank]
34
34
  frames [--demo-frames N] # or use start/push-frame/stop
35
35
 
36
36
  Common options
@@ -362,6 +362,7 @@ async function cmdRun(args) {
362
362
  match,
363
363
  durationMs,
364
364
  cursor: Boolean(args.cursor),
365
+ allowBlank: Boolean(args["allow-blank"]),
365
366
  fps: common.fps ?? 10,
366
367
  });
367
368
  process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
package/dist/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export { finalizeManifest, sinkMovie } from "./sink.js";
6
6
  export { formatSourceCatalog, recommendSource, SOURCE_CATALOG, SOURCE_DECISION_TABLE, sourceHintForError, } from "./source-help.js";
7
7
  export type { SourceAdvice, SourceKindHelp } from "./source-help.js";
8
8
  export { recordBrowserMovie } from "./sources/browser.js";
9
- export { assertDesktopToolchain, checkScreenRecordingAccess, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
9
+ export { assertDesktopToolchain, checkScreenRecordingAccess, describeDesktopWindow, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, isNearlyBlankPng, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
10
10
  export type { DesktopWindowInfo, DesktopWindowMatch, DesktopWindowMovieOptions, ScreenAccessReport, } from "./sources/desktop-macos.js";
11
11
  export { loadFrameSession, markFrameSession, pushFrameToSession, startFrameSession, stopFrameSession, } from "./sources/frames-store.js";
12
12
  export { loadPtyMovieFixture, recordPtyMovie, recordTruecolorDemoMovie, } from "./sources/pty.js";
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ export { MovieSession } from "./session.js";
5
5
  export { finalizeManifest, sinkMovie } from "./sink.js";
6
6
  export { formatSourceCatalog, recommendSource, SOURCE_CATALOG, SOURCE_DECISION_TABLE, sourceHintForError, } from "./source-help.js";
7
7
  export { recordBrowserMovie } from "./sources/browser.js";
8
- export { assertDesktopToolchain, checkScreenRecordingAccess, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
8
+ export { assertDesktopToolchain, checkScreenRecordingAccess, describeDesktopWindow, desktopMatchFromFlags, ensureScreenRecordingAccess, formatScreenRecordingDeniedHelp, isNearlyBlankPng, listDesktopWindows, matchDesktopWindow, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
9
9
  export { loadFrameSession, markFrameSession, pushFrameToSession, startFrameSession, stopFrameSession, } from "./sources/frames-store.js";
10
10
  export { loadPtyMovieFixture, recordPtyMovie, recordTruecolorDemoMovie, } from "./sources/pty.js";
11
11
  export { createHeadlessTerminal, terminalPlainText, terminalToHtml, writeTerminal, } from "./terminal-paint.js";
@@ -19,6 +19,9 @@ export declare function formatSourceCatalog(): string;
19
19
  /**
20
20
  * Lightweight advisor for agents: pass free-text intent, get a recommended source.
21
21
  * This is heuristic — the decision table is authoritative.
22
+ *
23
+ * Order matters: TUI beats native (don't desktop Terminal.app); native/menu-bar
24
+ * beats browser so "record the Astroshots tray" never defaults to Chromium.
22
25
  */
23
26
  export declare function recommendSource(intent: string): {
24
27
  source: SourceKindHelp;
@@ -15,6 +15,7 @@ Pick the FIRST row that matches the thing you need to record:
15
15
  | A TUI / CLI / Ink / Ratatui / truecolor terminal | pty | SGR→xterm truecolor path; NEVER screenshot Terminal.app |
16
16
  | Color-critical terminal (brand purple, etc.) | pty | Host terminal themes remapping 16 colors would lie |
17
17
  | A native macOS app window (SwiftUI, Electron…) | desktop.window | Real window pixels via screencapture; needs Screen Recording |
18
+ | Menu-bar / tray / status-item / LSUIElement app | desktop.window | Real app chrome; list-windows may include popover layers |
18
19
  | The whole monitor / multi-window desktop | desktop.display | (not implemented yet — use desktop.window or frames) |
19
20
  | Frames from any other tool (Unity, remote, custom)| frames | You push PNG/JPEG; harness only encodes + sinks |
20
21
  | You already have PNG frames on disk | frames | Multi-process start/push-frame/stop |
@@ -104,6 +105,7 @@ export const SOURCE_CATALOG = {
104
105
  summary: "macOS native window pixels via CGWindowList + screencapture -l sampling.",
105
106
  useWhen: [
106
107
  "SwiftUI / AppKit / Electron / any real Mac window",
108
+ "Menu-bar / tray / status-item / LSUIElement apps (e.g. Astroshots)",
107
109
  "You need the actual app chrome and OS rendering",
108
110
  ],
109
111
  neverWhen: [
@@ -177,6 +179,9 @@ export function formatSourceCatalog() {
177
179
  /**
178
180
  * Lightweight advisor for agents: pass free-text intent, get a recommended source.
179
181
  * This is heuristic — the decision table is authoritative.
182
+ *
183
+ * Order matters: TUI beats native (don't desktop Terminal.app); native/menu-bar
184
+ * beats browser so "record the Astroshots tray" never defaults to Chromium.
180
185
  */
181
186
  export function recommendSource(intent) {
182
187
  const text = intent.toLowerCase();
@@ -186,9 +191,13 @@ export function recommendSource(intent) {
186
191
  reason: "Terminal/TUI intent detected — use pty for truecolor SGR fidelity (not desktop of a terminal app).",
187
192
  };
188
193
  }
189
- if (/\b(swiftui|appkit|electron|native app|macos app|mac app|native mac|menu ?bar|desktop window|bundle[- ]?id|window id)\b/.test(text) ||
190
- /\b(swiftui|appkit|electron)\b/.test(text) ||
191
- (/\bnative\b/.test(text) && /\b(window|app|desktop|macos|mac)\b/.test(text))) {
194
+ if (/\b(swiftui|appkit|electron|native app|macos app|mac app|native mac|menu[- ]?bar|menubar|status[- ]?item|lsuielement|popover|desktop window|bundle[- ]?id|window id|window[- ]?id)\b/.test(text) ||
195
+ // "tray" alone is ambiguous (web trays exist); with an app name / menu-bar
196
+ // product context, treat as native desktop capture.
197
+ /\b(tray|menu bar tray)\b/.test(text) ||
198
+ /\bastroshots?\b/.test(text) ||
199
+ (/\bnative\b/.test(text) &&
200
+ /\b(window|app|desktop|macos|mac|chrome|ui)\b/.test(text))) {
192
201
  return {
193
202
  source: "desktop.window",
194
203
  reason: "Native desktop window intent detected — use desktop.window with --bundle-id or --window-id.",
@@ -10,6 +10,8 @@ export interface DesktopWindowInfo {
10
10
  x: number;
11
11
  y: number;
12
12
  onScreen: boolean;
13
+ /** CGWindow layer; 0 = normal, >0 = floating/popover chrome. */
14
+ layer?: number;
13
15
  }
14
16
  export interface DesktopWindowMatch {
15
17
  windowId?: number;
@@ -19,6 +21,11 @@ export interface DesktopWindowMatch {
19
21
  pid?: number;
20
22
  /** When multiple match, pick largest (default) or first. */
21
23
  pick?: "largest" | "first";
24
+ /**
25
+ * Prefer on-screen windows when several match (default true).
26
+ * Off-screen / empty host windows often produce black frames.
27
+ */
28
+ preferOnScreen?: boolean;
22
29
  }
23
30
  export type DesktopWindowMovieOptions = Omit<MovieSessionOptions, "source"> & {
24
31
  match: DesktopWindowMatch;
@@ -26,6 +33,11 @@ export type DesktopWindowMovieOptions = Omit<MovieSessionOptions, "source"> & {
26
33
  durationMs?: number;
27
34
  /** Include cursor in frames (screencapture -C). Default false. */
28
35
  cursor?: boolean;
36
+ /**
37
+ * Allow mostly-blank / black posters to encode (default false).
38
+ * Off-screen windows and denied Screen Recording often produce black frames.
39
+ */
40
+ allowBlank?: boolean;
29
41
  };
30
42
  export interface ScreenAccessReport {
31
43
  granted: boolean;
@@ -70,6 +82,16 @@ export declare function ensureScreenRecordingAccess(options?: {
70
82
  /** List layer-0 windows as JSON via shipped Swift tool (interpreted by `swift`). */
71
83
  export declare function listDesktopWindows(): DesktopWindowInfo[];
72
84
  export declare function matchDesktopWindow(windows: DesktopWindowInfo[], match: DesktopWindowMatch): DesktopWindowInfo;
85
+ /** Human-readable manifest description for a captured window. */
86
+ export declare function describeDesktopWindow(target: DesktopWindowInfo): string;
87
+ /**
88
+ * True when a PNG is almost entirely very dark (typical failed/off-screen capture).
89
+ * Samples up to ~4k pixels across a simple grid; supports 8-bit RGB/RGBA.
90
+ */
91
+ export declare function isNearlyBlankPng(filePath: string, options?: {
92
+ maxMeanLuma?: number;
93
+ minDarkFraction?: number;
94
+ }): boolean;
73
95
  /**
74
96
  * Sample a macOS window at `fps` for `durationMs`, encode to movie + poster.
75
97
  * Uses OS `screencapture` (already on every Mac) — no separate download.
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import zlib from "node:zlib";
6
7
  import { MovieSession } from "../session.js";
7
8
  const SCREENCAPTURE = "/usr/sbin/screencapture";
8
9
  /** Known deep-links to the Screen Recording privacy pane (varies by macOS). */
@@ -200,16 +201,126 @@ export function matchDesktopWindow(windows, match) {
200
201
  if (candidates.length === 0) {
201
202
  const sample = windows
202
203
  .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
+ .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}`)
204
205
  .join("\n");
205
206
  throw new Error(`No desktop window matched ${JSON.stringify(match)}.\n` +
206
207
  `Run: astroshot movie list-windows\n` +
207
208
  `Sample windows:\n${sample || " (none)"}`);
208
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
+ }
209
216
  if (match.pick === "first")
210
217
  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];
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;
213
324
  }
214
325
  function captureWindowPng(windowId, outPath, cursor) {
215
326
  const finalArgs = cursor
@@ -279,6 +390,13 @@ export async function recordDesktopWindowMovie(options) {
279
390
  ensureScreenRecordingAccess({ request: true, openSettings: true });
280
391
  const windows = listDesktopWindows();
281
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
+ }
282
400
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-desktop-"));
283
401
  const probe = path.join(tmp, "probe.png");
284
402
  try {
@@ -288,6 +406,14 @@ export async function recordDesktopWindowMovie(options) {
288
406
  fs.rmSync(tmp, { recursive: true, force: true });
289
407
  throw error;
290
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
+ }
291
417
  const probed = readPngSize(probe);
292
418
  const size = options.size ??
293
419
  probed ?? {
@@ -299,8 +425,7 @@ export async function recordDesktopWindowMovie(options) {
299
425
  size,
300
426
  fps,
301
427
  source: "desktop.window",
302
- description: options.description ??
303
- `desktop.window id=${target.id} ${target.bundleId ?? target.owner} ${JSON.stringify(target.title)}`,
428
+ description: options.description ?? describeDesktopWindow(target),
304
429
  });
305
430
  // Seed with probe frame so we never end empty if duration is tiny.
306
431
  session.pushFrame(fs.readFileSync(probe));
@@ -21,6 +21,8 @@ struct WindowRow: Encodable {
21
21
  let x: Int
22
22
  let y: Int
23
23
  let onScreen: Bool
24
+ /// CGWindow layer (0 = normal). Popovers / floating chrome often use > 0.
25
+ let layer: Int
24
26
  }
25
27
 
26
28
  struct ScreenAccessReport: Encodable {
@@ -50,7 +52,10 @@ func listWindows() -> [WindowRow] {
50
52
  var rows: [WindowRow] = []
51
53
  for w in info {
52
54
  let layer = w[kCGWindowLayer as String] as? Int ?? -1
53
- guard layer == 0 else { continue }
55
+ // Layer 0 = normal windows. Menu-bar trays / NSPopover often sit above 0
56
+ // (floating ~3, modal panel ~8). Skip Dock / main menu / status chrome
57
+ // (typically ≥20) which are huge, blank under screencapture, or unusable.
58
+ guard layer >= 0, layer <= 15 else { continue }
54
59
 
55
60
  let id = w[kCGWindowNumber as String] as? Int ?? 0
56
61
  let pid = w[kCGWindowOwnerPID as String] as? Int ?? 0
@@ -62,6 +67,7 @@ func listWindows() -> [WindowRow] {
62
67
  let height = (bounds?["Height"] as? NSNumber)?.intValue ?? 0
63
68
  let x = (bounds?["X"] as? NSNumber)?.intValue ?? 0
64
69
  let y = (bounds?["Y"] as? NSNumber)?.intValue ?? 0
70
+ // Popovers can be small; keep a low floor but drop 1×1 placeholders.
65
71
  if width < 2 || height < 2 { continue }
66
72
 
67
73
  rows.append(
@@ -75,13 +81,21 @@ func listWindows() -> [WindowRow] {
75
81
  height: height,
76
82
  x: x,
77
83
  y: y,
78
- onScreen: onScreen
84
+ onScreen: onScreen,
85
+ layer: layer
79
86
  )
80
87
  )
81
88
  }
82
89
 
83
- // Largest first better default when multiple windows share a bundle id.
84
- return rows.sorted { ($0.width * $0.height) > ($1.width * $1.height) }
90
+ // Prefer larger + on-screen + lower layer so normal windows win over
91
+ // tiny floating helpers when multiple match a bundle id.
92
+ return rows.sorted {
93
+ let area0 = $0.width * $0.height
94
+ let area1 = $1.width * $1.height
95
+ if area0 != area1 { return area0 > area1 }
96
+ if $0.onScreen != $1.onScreen { return $0.onScreen && !$1.onScreen }
97
+ return $0.layer < $1.layer
98
+ }
85
99
  }
86
100
 
87
101
  func hostIdentity() -> (name: String, bundleId: String?) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@archastro/movie-harness",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Universal movie capture harness for browser, PTY, desktop, and raw frames into .astroshot/",
5
5
  "keywords": [
6
6
  "astroshots",