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,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { assertKebabCase, assertSlug, ensureDir, featureDir, humanize, nextSequence, } from "./paths.js";
|
|
4
|
+
function readManifest(manifestPath) {
|
|
5
|
+
if (!fs.existsSync(manifestPath))
|
|
6
|
+
return null;
|
|
7
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
8
|
+
if (!raw || typeof raw !== "object")
|
|
9
|
+
return null;
|
|
10
|
+
if (!Array.isArray(raw.shots))
|
|
11
|
+
raw.shots = [];
|
|
12
|
+
return raw;
|
|
13
|
+
}
|
|
14
|
+
function writeAtomic(filePath, contents) {
|
|
15
|
+
const dir = path.dirname(filePath);
|
|
16
|
+
ensureDir(dir);
|
|
17
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}`);
|
|
18
|
+
fs.writeFileSync(tmp, contents);
|
|
19
|
+
fs.renameSync(tmp, filePath);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Publish poster + video into .astroshot/<feature>/ and append a movie shot
|
|
23
|
+
* to manifest.json. Poster basename is the review key (matches stills).
|
|
24
|
+
*/
|
|
25
|
+
export function sinkMovie(request) {
|
|
26
|
+
assertKebabCase(request.feature, "feature");
|
|
27
|
+
assertSlug(request.slug);
|
|
28
|
+
const dir = featureDir(request.root, request.feature);
|
|
29
|
+
ensureDir(dir);
|
|
30
|
+
const sequence = nextSequence(dir);
|
|
31
|
+
const posterName = `${sequence}-${request.slug}.png`;
|
|
32
|
+
const videoExt = path.extname(request.videoPath).toLowerCase() || ".webm";
|
|
33
|
+
const videoName = `${sequence}-${request.slug}${videoExt}`;
|
|
34
|
+
const posterDest = path.join(dir, posterName);
|
|
35
|
+
const videoDest = path.join(dir, videoName);
|
|
36
|
+
if (!fs.existsSync(request.posterPath)) {
|
|
37
|
+
throw new Error(`poster not found: ${request.posterPath}`);
|
|
38
|
+
}
|
|
39
|
+
if (!fs.existsSync(request.videoPath)) {
|
|
40
|
+
throw new Error(`video not found: ${request.videoPath}`);
|
|
41
|
+
}
|
|
42
|
+
fs.copyFileSync(request.posterPath, posterDest);
|
|
43
|
+
fs.copyFileSync(request.videoPath, videoDest);
|
|
44
|
+
const manifestPath = path.join(dir, "manifest.json");
|
|
45
|
+
const existing = readManifest(manifestPath);
|
|
46
|
+
const continueRun = existing &&
|
|
47
|
+
existing.run_id === request.runId &&
|
|
48
|
+
existing.status === "running";
|
|
49
|
+
const shot = {
|
|
50
|
+
id: sequence,
|
|
51
|
+
file: posterName,
|
|
52
|
+
slug: request.slug,
|
|
53
|
+
title: request.title ?? humanize(request.slug),
|
|
54
|
+
description: request.description,
|
|
55
|
+
captured_at: new Date().toISOString(),
|
|
56
|
+
viewport: request.size
|
|
57
|
+
? `${request.size.width}x${request.size.height}`
|
|
58
|
+
: undefined,
|
|
59
|
+
kind: "movie",
|
|
60
|
+
video: videoName,
|
|
61
|
+
duration_ms: Math.round(request.durationMs),
|
|
62
|
+
source: request.source,
|
|
63
|
+
chapters: request.chapters.map((chapter) => ({
|
|
64
|
+
slug: chapter.slug,
|
|
65
|
+
t_ms: Math.round(chapter.tMs),
|
|
66
|
+
note: chapter.note,
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
const manifest = continueRun
|
|
70
|
+
? {
|
|
71
|
+
...existing,
|
|
72
|
+
status: request.status ?? existing.status,
|
|
73
|
+
description: request.description ?? existing.description,
|
|
74
|
+
shots: [...existing.shots, shot],
|
|
75
|
+
}
|
|
76
|
+
: {
|
|
77
|
+
version: 1,
|
|
78
|
+
feature: request.feature,
|
|
79
|
+
run_id: request.runId,
|
|
80
|
+
status: request.status ?? "running",
|
|
81
|
+
description: request.description,
|
|
82
|
+
shots: [shot],
|
|
83
|
+
};
|
|
84
|
+
writeAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
85
|
+
return {
|
|
86
|
+
sequence,
|
|
87
|
+
posterDest,
|
|
88
|
+
videoDest,
|
|
89
|
+
manifestPath,
|
|
90
|
+
featureDir: dir,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function finalizeManifest(root, feature, runId, status) {
|
|
94
|
+
assertKebabCase(feature, "feature");
|
|
95
|
+
const dir = featureDir(root, feature);
|
|
96
|
+
const manifestPath = path.join(dir, "manifest.json");
|
|
97
|
+
const existing = readManifest(manifestPath);
|
|
98
|
+
if (!existing) {
|
|
99
|
+
writeAtomic(manifestPath, `${JSON.stringify({
|
|
100
|
+
version: 1,
|
|
101
|
+
feature,
|
|
102
|
+
run_id: runId,
|
|
103
|
+
status,
|
|
104
|
+
shots: [],
|
|
105
|
+
}, null, 2)}\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (existing.run_id !== runId) {
|
|
109
|
+
throw new Error(`manifest run_id ${JSON.stringify(existing.run_id)} does not match ${JSON.stringify(runId)}`);
|
|
110
|
+
}
|
|
111
|
+
existing.status = status;
|
|
112
|
+
writeAtomic(manifestPath, `${JSON.stringify(existing, null, 2)}\n`);
|
|
113
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source selection guidance for humans and coding agents.
|
|
3
|
+
* Keep this the single source of truth for "which --source should I use?"
|
|
4
|
+
*/
|
|
5
|
+
export declare const SOURCE_DECISION_TABLE: string;
|
|
6
|
+
export type SourceKindHelp = "browser" | "pty" | "pty-demo" | "desktop.window" | "desktop.display" | "desktop.region" | "frames";
|
|
7
|
+
export interface SourceAdvice {
|
|
8
|
+
source: SourceKindHelp;
|
|
9
|
+
summary: string;
|
|
10
|
+
useWhen: string[];
|
|
11
|
+
neverWhen: string[];
|
|
12
|
+
requiredFlags: string[];
|
|
13
|
+
example: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const SOURCE_CATALOG: Record<SourceKindHelp, SourceAdvice>;
|
|
16
|
+
/** Short one-liner for errors when source is wrong/missing. */
|
|
17
|
+
export declare function sourceHintForError(kind?: string): string;
|
|
18
|
+
export declare function formatSourceCatalog(): string;
|
|
19
|
+
/**
|
|
20
|
+
* Lightweight advisor for agents: pass free-text intent, get a recommended source.
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
export declare function recommendSource(intent: string): {
|
|
27
|
+
source: SourceKindHelp;
|
|
28
|
+
reason: string;
|
|
29
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source selection guidance for humans and coding agents.
|
|
3
|
+
* Keep this the single source of truth for "which --source should I use?"
|
|
4
|
+
*/
|
|
5
|
+
export const SOURCE_DECISION_TABLE = `
|
|
6
|
+
Which --source should I use?
|
|
7
|
+
============================
|
|
8
|
+
|
|
9
|
+
Pick the FIRST row that matches the thing you need to record:
|
|
10
|
+
|
|
11
|
+
| You need to record… | Use --source | Why |
|
|
12
|
+
|--------------------------------------------------|---------------------|-----|
|
|
13
|
+
| A web page / SPA / agent-browser session | browser | Headless Chromium; no Screen Recording TCC; deterministic viewport |
|
|
14
|
+
| An isolated React component (not a full app) | browser (or still) | Prefer stills via \`astroshot react\` unless motion matters |
|
|
15
|
+
| A TUI / CLI / Ink / Ratatui / truecolor terminal | pty | SGR→xterm truecolor path; NEVER screenshot Terminal.app |
|
|
16
|
+
| Color-critical terminal (brand purple, etc.) | pty | Host terminal themes remapping 16 colors would lie |
|
|
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 |
|
|
19
|
+
| The whole monitor / multi-window desktop | desktop.display | (not implemented yet — use desktop.window or frames) |
|
|
20
|
+
| Frames from any other tool (Unity, remote, custom)| frames | You push PNG/JPEG; harness only encodes + sinks |
|
|
21
|
+
| You already have PNG frames on disk | frames | Multi-process start/push-frame/stop |
|
|
22
|
+
|
|
23
|
+
Hard rules for agents
|
|
24
|
+
---------------------
|
|
25
|
+
1. Terminal/TUI color → always \`pty\` (or \`pty-demo\` for a truecolor smoke test).
|
|
26
|
+
Do NOT use desktop.window on Terminal.app / iTerm / Ghostty for TUI review.
|
|
27
|
+
2. Web UI → \`browser\`, not desktop of a browser window (loses headless CI + viewport control).
|
|
28
|
+
3. Native Mac app chrome → \`desktop.window\` with --bundle-id or --window-id.
|
|
29
|
+
4. Unknown engine that can dump images → \`frames\`.
|
|
30
|
+
5. Always write into .astroshot/ via this harness (poster PNG + video) so Astroshots can stream posters today.
|
|
31
|
+
|
|
32
|
+
Permission / environment
|
|
33
|
+
------------------------
|
|
34
|
+
| Source | Needs | CI-friendly? |
|
|
35
|
+
|-----------------|------------------------|--------------|
|
|
36
|
+
| browser | Playwright Chromium | yes (headless) |
|
|
37
|
+
| pty / pty-demo | node-pty (optional) | yes |
|
|
38
|
+
| frames | nothing special | yes |
|
|
39
|
+
| desktop.window | macOS + Screen Recording TCC + WindowTools (Swift) | hard |
|
|
40
|
+
|
|
41
|
+
Quick commands
|
|
42
|
+
--------------
|
|
43
|
+
# Web journey
|
|
44
|
+
astroshot movie run --source browser --feature f --slug s --url https://…
|
|
45
|
+
|
|
46
|
+
# Truecolor TUI fixture
|
|
47
|
+
astroshot movie run --source pty --feature f --slug s --fixture ./flow.pty.yaml
|
|
48
|
+
|
|
49
|
+
# Native app window (largest window of bundle, 3s)
|
|
50
|
+
astroshot movie run --source desktop.window --feature f --slug s \\
|
|
51
|
+
--bundle-id com.example.App --duration-ms 3000
|
|
52
|
+
|
|
53
|
+
# List windows (macOS)
|
|
54
|
+
astroshot movie list-windows
|
|
55
|
+
|
|
56
|
+
# Push your own frames
|
|
57
|
+
astroshot movie start --feature f --slug s
|
|
58
|
+
astroshot movie push-frame --feature f --file ./frame.png
|
|
59
|
+
astroshot movie stop --feature f --status pass
|
|
60
|
+
`.trim();
|
|
61
|
+
export const SOURCE_CATALOG = {
|
|
62
|
+
browser: {
|
|
63
|
+
source: "browser",
|
|
64
|
+
summary: "Headless Chromium viewport movie via Playwright recordVideo.",
|
|
65
|
+
useWhen: [
|
|
66
|
+
"Recording a web app, SPA, or agent-browser session",
|
|
67
|
+
"You need a fixed viewport and no OS permissions",
|
|
68
|
+
"CI / headless environments",
|
|
69
|
+
],
|
|
70
|
+
neverWhen: [
|
|
71
|
+
"Recording a TUI (use pty)",
|
|
72
|
+
"Recording native app chrome (use desktop.window)",
|
|
73
|
+
],
|
|
74
|
+
requiredFlags: ["--feature", "--slug"],
|
|
75
|
+
example: "astroshot movie run --source browser --feature web --slug home --url https://example.com --settle-ms 500",
|
|
76
|
+
},
|
|
77
|
+
pty: {
|
|
78
|
+
source: "pty",
|
|
79
|
+
summary: "Truecolor terminal movie: node-pty → xterm SGR cells → Chromium frames.",
|
|
80
|
+
useWhen: [
|
|
81
|
+
"Ink, Ratatui, Bubble Tea, curses, or any CLI TUI",
|
|
82
|
+
"Color accuracy matters (truecolor / 256-color)",
|
|
83
|
+
"Deterministic fixture YAML/JSON journeys",
|
|
84
|
+
],
|
|
85
|
+
neverWhen: [
|
|
86
|
+
"Screenshotting Terminal.app/iTerm/Ghostty with desktop.window",
|
|
87
|
+
"Web UIs",
|
|
88
|
+
],
|
|
89
|
+
requiredFlags: ["--feature", "--slug", "--fixture"],
|
|
90
|
+
example: "astroshot movie run --source pty --feature tui --slug flow --fixture ./flow.pty.yaml",
|
|
91
|
+
},
|
|
92
|
+
"pty-demo": {
|
|
93
|
+
source: "pty-demo",
|
|
94
|
+
summary: "Built-in truecolor SGR smoke test (no external program).",
|
|
95
|
+
useWhen: [
|
|
96
|
+
"Verifying the truecolor paint/encode path",
|
|
97
|
+
"CI smoke without a real TUI binary",
|
|
98
|
+
],
|
|
99
|
+
neverWhen: ["Production journey capture (use pty + fixture)"],
|
|
100
|
+
requiredFlags: ["--feature", "--slug"],
|
|
101
|
+
example: "astroshot movie run --source pty-demo --feature tui --slug brand",
|
|
102
|
+
},
|
|
103
|
+
"desktop.window": {
|
|
104
|
+
source: "desktop.window",
|
|
105
|
+
summary: "macOS native window pixels via CGWindowList + screencapture -l sampling.",
|
|
106
|
+
useWhen: [
|
|
107
|
+
"SwiftUI / AppKit / Electron / any real Mac window",
|
|
108
|
+
"Menu-bar / tray / status-item / LSUIElement apps (e.g. Astroshots)",
|
|
109
|
+
"You need the actual app chrome and OS rendering",
|
|
110
|
+
],
|
|
111
|
+
neverWhen: [
|
|
112
|
+
"TUIs (use pty — terminal themes lie about color)",
|
|
113
|
+
"Web-only journeys you can drive headlessly (use browser)",
|
|
114
|
+
"Linux/Windows CI without a Mac (not supported yet)",
|
|
115
|
+
],
|
|
116
|
+
requiredFlags: [
|
|
117
|
+
"--feature",
|
|
118
|
+
"--slug",
|
|
119
|
+
"one of: --window-id | --bundle-id | --title-regex | --owner | --pid",
|
|
120
|
+
],
|
|
121
|
+
example: "astroshot movie run --source desktop.window --feature app --slug onboard --bundle-id com.example.App --duration-ms 4000 --fps 10",
|
|
122
|
+
},
|
|
123
|
+
"desktop.display": {
|
|
124
|
+
source: "desktop.display",
|
|
125
|
+
summary: "Full display capture (planned).",
|
|
126
|
+
useWhen: ["Whole-monitor demos"],
|
|
127
|
+
neverWhen: ["Prefer desktop.window when a single app matters"],
|
|
128
|
+
requiredFlags: ["(not implemented)"],
|
|
129
|
+
example: "astroshot movie run --source desktop.window … # until desktop.display ships",
|
|
130
|
+
},
|
|
131
|
+
"desktop.region": {
|
|
132
|
+
source: "desktop.region",
|
|
133
|
+
summary: "Display region crop (planned).",
|
|
134
|
+
useWhen: ["Fixed rectangle on a display"],
|
|
135
|
+
neverWhen: ["Prefer desktop.window when possible"],
|
|
136
|
+
requiredFlags: ["(not implemented)"],
|
|
137
|
+
example: "astroshot movie start --feature x --slug region # push cropped frames for now",
|
|
138
|
+
},
|
|
139
|
+
frames: {
|
|
140
|
+
source: "frames",
|
|
141
|
+
summary: "Encode a PNG/JPEG sequence you already produce.",
|
|
142
|
+
useWhen: [
|
|
143
|
+
"Custom engines, remote desktops, game captures",
|
|
144
|
+
"Multi-process producers that write images over time",
|
|
145
|
+
"Synthetic / test patterns",
|
|
146
|
+
],
|
|
147
|
+
neverWhen: [
|
|
148
|
+
"You have a first-class source above that fits — use it instead",
|
|
149
|
+
],
|
|
150
|
+
requiredFlags: ["--feature", "--slug", "then push-frame or --demo-frames"],
|
|
151
|
+
example: "astroshot movie start --feature x --slug walk && astroshot movie push-frame --feature x --file f.png && astroshot movie stop --feature x",
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
/** Short one-liner for errors when source is wrong/missing. */
|
|
155
|
+
export function sourceHintForError(kind) {
|
|
156
|
+
if (kind && kind in SOURCE_CATALOG) {
|
|
157
|
+
const entry = SOURCE_CATALOG[kind];
|
|
158
|
+
return `${entry.summary}\n example: ${entry.example}`;
|
|
159
|
+
}
|
|
160
|
+
return ("See `astroshot movie which-source` or `astroshot movie --help` for the decision table.");
|
|
161
|
+
}
|
|
162
|
+
export function formatSourceCatalog() {
|
|
163
|
+
const blocks = Object.values(SOURCE_CATALOG).map((entry) => {
|
|
164
|
+
const use = entry.useWhen.map((line) => ` • ${line}`).join("\n");
|
|
165
|
+
const never = entry.neverWhen.map((line) => ` • ${line}`).join("\n");
|
|
166
|
+
return [
|
|
167
|
+
`--source ${entry.source}`,
|
|
168
|
+
` ${entry.summary}`,
|
|
169
|
+
` Use when:`,
|
|
170
|
+
use,
|
|
171
|
+
` Never when:`,
|
|
172
|
+
never,
|
|
173
|
+
` Required: ${entry.requiredFlags.join(", ")}`,
|
|
174
|
+
` Example: ${entry.example}`,
|
|
175
|
+
].join("\n");
|
|
176
|
+
});
|
|
177
|
+
return `${SOURCE_DECISION_TABLE}\n\nSource catalog\n--------------\n\n${blocks.join("\n\n")}\n`;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Lightweight advisor for agents: pass free-text intent, get a recommended source.
|
|
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.
|
|
185
|
+
*/
|
|
186
|
+
export function recommendSource(intent) {
|
|
187
|
+
const text = intent.toLowerCase();
|
|
188
|
+
if (/\b(tui|pty|terminal|ink|ratatui|curses|bubbletea|cli app|truecolor|ansi)\b/.test(text)) {
|
|
189
|
+
return {
|
|
190
|
+
source: "pty",
|
|
191
|
+
reason: "Terminal/TUI intent detected — use pty for truecolor SGR fidelity (not desktop of a terminal app).",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
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))) {
|
|
201
|
+
return {
|
|
202
|
+
source: "desktop.window",
|
|
203
|
+
reason: "Native desktop window intent detected — use desktop.window with --bundle-id or --window-id.",
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (/\b(browser|web|spa|playwright|agent-browser|http|react page|url)\b/.test(text)) {
|
|
207
|
+
return {
|
|
208
|
+
source: "browser",
|
|
209
|
+
reason: "Web/browser intent detected — use browser (headless Chromium), not a desktop capture of Chrome.",
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (/\b(frame|png|jpeg|sequence|custom|unity|remote)\b/.test(text)) {
|
|
213
|
+
return {
|
|
214
|
+
source: "frames",
|
|
215
|
+
reason: "Custom frame producer intent — use frames and push-frame.",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
source: "browser",
|
|
220
|
+
reason: "No strong signal; defaulting to browser for web-shaped work. Run `astroshot movie which-source` and match the decision table.",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { BrowserMovieOptions, MovieArtifact, MovieSessionOptions } from "../types.js";
|
|
2
|
+
export type BrowserMovieSessionOptions = Omit<MovieSessionOptions, "source"> & BrowserMovieOptions;
|
|
3
|
+
/**
|
|
4
|
+
* Record a headless (or headed) browser journey with Playwright recordVideo.
|
|
5
|
+
* Optional `scriptPath` should export `default async function(page)`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function recordBrowserMovie(options: BrowserMovieSessionOptions): Promise<MovieArtifact>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { chromium } from "playwright";
|
|
6
|
+
import { MovieSession } from "../session.js";
|
|
7
|
+
/**
|
|
8
|
+
* Record a headless (or headed) browser journey with Playwright recordVideo.
|
|
9
|
+
* Optional `scriptPath` should export `default async function(page)`.
|
|
10
|
+
*/
|
|
11
|
+
export async function recordBrowserMovie(options) {
|
|
12
|
+
const size = options.size ?? { width: 1280, height: 720 };
|
|
13
|
+
const session = MovieSession.create({
|
|
14
|
+
...options,
|
|
15
|
+
size,
|
|
16
|
+
source: "browser",
|
|
17
|
+
});
|
|
18
|
+
const videoDir = fs.mkdtempSync(path.join(os.tmpdir(), "astroshot-bw-"));
|
|
19
|
+
let browser;
|
|
20
|
+
try {
|
|
21
|
+
browser = await chromium.launch({ headless: !options.headed });
|
|
22
|
+
const context = await browser.newContext({
|
|
23
|
+
viewport: size,
|
|
24
|
+
deviceScaleFactor: 1,
|
|
25
|
+
recordVideo: {
|
|
26
|
+
dir: videoDir,
|
|
27
|
+
size,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
const page = await context.newPage();
|
|
31
|
+
if (options.url) {
|
|
32
|
+
await page.goto(options.url, {
|
|
33
|
+
waitUntil: "domcontentloaded",
|
|
34
|
+
timeout: 60_000,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
if (options.scriptPath) {
|
|
38
|
+
await runScript(options.scriptPath, page);
|
|
39
|
+
}
|
|
40
|
+
else if (!options.url) {
|
|
41
|
+
// Default demo surface so a bare call still produces a movie.
|
|
42
|
+
await page.setContent(`<!doctype html>
|
|
43
|
+
<html><body style="margin:0;display:grid;place-items:center;height:100vh;background:#090a12;color:#b9a8ff;font:28px ui-monospace,monospace">
|
|
44
|
+
<div>astroshot-movie browser</div>
|
|
45
|
+
</body></html>`, { waitUntil: "load" });
|
|
46
|
+
await page.waitForTimeout(400);
|
|
47
|
+
}
|
|
48
|
+
if (options.settleMs && options.settleMs > 0) {
|
|
49
|
+
await page.waitForTimeout(options.settleMs);
|
|
50
|
+
}
|
|
51
|
+
const posterPath = path.join(videoDir, "poster.png");
|
|
52
|
+
await page.screenshot({ path: posterPath, type: "png" });
|
|
53
|
+
// Keep a frame so stop() has a poster fallback even if video path is set.
|
|
54
|
+
session.pushFrame(fs.readFileSync(posterPath));
|
|
55
|
+
await context.close();
|
|
56
|
+
const videos = fs
|
|
57
|
+
.readdirSync(videoDir)
|
|
58
|
+
.filter((name) => name.endsWith(".webm"))
|
|
59
|
+
.map((name) => path.join(videoDir, name));
|
|
60
|
+
if (videos.length === 0) {
|
|
61
|
+
throw new Error("Playwright did not produce a browser video");
|
|
62
|
+
}
|
|
63
|
+
videos.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
64
|
+
return await session.stop({
|
|
65
|
+
videoPath: videos[0],
|
|
66
|
+
posterPath,
|
|
67
|
+
durationMs: session.elapsedMs(),
|
|
68
|
+
status: options.status ?? "running",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await browser?.close().catch(() => undefined);
|
|
73
|
+
fs.rmSync(videoDir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function runScript(scriptPath, page) {
|
|
77
|
+
const absolute = path.resolve(scriptPath);
|
|
78
|
+
if (!fs.existsSync(absolute)) {
|
|
79
|
+
throw new Error(`browser script not found: ${absolute}`);
|
|
80
|
+
}
|
|
81
|
+
const mod = (await import(pathToFileURL(absolute).href));
|
|
82
|
+
const runner = mod.default ?? mod.run;
|
|
83
|
+
if (typeof runner !== "function") {
|
|
84
|
+
throw new Error(`browser script must export default or run async function(page): ${absolute}`);
|
|
85
|
+
}
|
|
86
|
+
await runner(page);
|
|
87
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { MovieArtifact, MovieSessionOptions } from "../types.js";
|
|
2
|
+
export interface DesktopWindowInfo {
|
|
3
|
+
id: number;
|
|
4
|
+
pid: number;
|
|
5
|
+
owner: string;
|
|
6
|
+
title: string;
|
|
7
|
+
bundleId: string | null;
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
onScreen: boolean;
|
|
13
|
+
/** CGWindow layer; 0 = normal, >0 = floating/popover chrome. */
|
|
14
|
+
layer?: number;
|
|
15
|
+
}
|
|
16
|
+
export interface DesktopWindowMatch {
|
|
17
|
+
windowId?: number;
|
|
18
|
+
bundleId?: string;
|
|
19
|
+
titleRegex?: string;
|
|
20
|
+
owner?: string;
|
|
21
|
+
pid?: number;
|
|
22
|
+
/** When multiple match, pick largest (default) or first. */
|
|
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;
|
|
29
|
+
}
|
|
30
|
+
export type DesktopWindowMovieOptions = Omit<MovieSessionOptions, "source"> & {
|
|
31
|
+
match: DesktopWindowMatch;
|
|
32
|
+
/** How long to sample. Default 3000. */
|
|
33
|
+
durationMs?: number;
|
|
34
|
+
/** Include cursor in frames (screencapture -C). Default false. */
|
|
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;
|
|
41
|
+
};
|
|
42
|
+
export interface ScreenAccessReport {
|
|
43
|
+
granted: boolean;
|
|
44
|
+
requested: boolean;
|
|
45
|
+
hostApp: string;
|
|
46
|
+
hostBundleId: string | null;
|
|
47
|
+
settingsHint: string;
|
|
48
|
+
/** Best-effort name of the app the user should enable (terminal/IDE). */
|
|
49
|
+
enableApp: string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Open System Settings to the Screen Recording privacy list.
|
|
53
|
+
* Best-effort: URL schemes differ slightly across macOS versions.
|
|
54
|
+
*/
|
|
55
|
+
export declare function openScreenRecordingSettings(): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Name the app the human should toggle in Screen Recording settings.
|
|
58
|
+
* Prefer $TERM_PROGRAM (Ghostty, iTerm, vscode…) over the Swift runner process.
|
|
59
|
+
*/
|
|
60
|
+
export declare function resolveEnableAppName(swiftHostApp?: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Detect Screen Recording TCC (best-effort).
|
|
63
|
+
*
|
|
64
|
+
* Uses CoreGraphics preflight via Swift. Note: the Swift process identity may
|
|
65
|
+
* differ from `screencapture`'s responsible app (your terminal). Capture
|
|
66
|
+
* failure remains authoritative; this steers the human to Settings early.
|
|
67
|
+
*/
|
|
68
|
+
export declare function checkScreenRecordingAccess(options?: {
|
|
69
|
+
/** Call CGRequestScreenCaptureAccess when not already granted. */
|
|
70
|
+
request?: boolean;
|
|
71
|
+
}): ScreenAccessReport;
|
|
72
|
+
export declare function formatScreenRecordingDeniedHelp(report?: ScreenAccessReport): string;
|
|
73
|
+
/**
|
|
74
|
+
* Preflight Screen Recording; optionally request + open Settings on deny.
|
|
75
|
+
* Call before desktop.window capture.
|
|
76
|
+
*/
|
|
77
|
+
export declare function ensureScreenRecordingAccess(options?: {
|
|
78
|
+
request?: boolean;
|
|
79
|
+
/** Open System Settings when denied (default true). */
|
|
80
|
+
openSettings?: boolean;
|
|
81
|
+
}): ScreenAccessReport;
|
|
82
|
+
/** List layer-0 windows as JSON via shipped Swift tool (interpreted by `swift`). */
|
|
83
|
+
export declare function listDesktopWindows(): DesktopWindowInfo[];
|
|
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;
|
|
95
|
+
/**
|
|
96
|
+
* Sample a macOS window at `fps` for `durationMs`, encode to movie + poster.
|
|
97
|
+
* Uses OS `screencapture` (already on every Mac) — no separate download.
|
|
98
|
+
*/
|
|
99
|
+
export declare function recordDesktopWindowMovie(options: DesktopWindowMovieOptions): Promise<MovieArtifact>;
|
|
100
|
+
/** Resolve match flags from CLI-style strings. */
|
|
101
|
+
export declare function desktopMatchFromFlags(flags: {
|
|
102
|
+
"window-id"?: string;
|
|
103
|
+
"bundle-id"?: string;
|
|
104
|
+
"title-regex"?: string;
|
|
105
|
+
owner?: string;
|
|
106
|
+
pid?: string;
|
|
107
|
+
pick?: string;
|
|
108
|
+
}): DesktopWindowMatch;
|
|
109
|
+
/** Ensure Swift is runnable (for clearer errors at CLI start). */
|
|
110
|
+
export declare function assertDesktopToolchain(): void;
|