@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.
- package/README.md +138 -0
- package/bin/astroshot-movie.mjs +5 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +388 -0
- package/dist/encode.d.ts +14 -0
- package/dist/encode.js +184 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +11 -0
- package/dist/paths.d.ts +9 -0
- package/dist/paths.js +57 -0
- package/dist/png.d.ts +8 -0
- package/dist/png.js +79 -0
- package/dist/session.d.ts +45 -0
- package/dist/session.js +170 -0
- package/dist/sink.d.ts +7 -0
- package/dist/sink.js +113 -0
- package/dist/source-help.d.ts +26 -0
- package/dist/source-help.js +213 -0
- package/dist/sources/browser.d.ts +7 -0
- package/dist/sources/browser.js +87 -0
- package/dist/sources/desktop-macos.d.ts +88 -0
- package/dist/sources/desktop-macos.js +380 -0
- package/dist/sources/frames-store.d.ts +19 -0
- package/dist/sources/frames-store.js +165 -0
- package/dist/sources/pty.d.ts +15 -0
- package/dist/sources/pty.js +330 -0
- package/dist/terminal-paint.d.ts +27 -0
- package/dist/terminal-paint.js +190 -0
- package/dist/types.d.ts +140 -0
- package/dist/types.js +1 -0
- package/native/macos/WindowTools.swift +150 -0
- package/package.json +67 -0
package/README.md
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# `@archastro/movie-harness`
|
|
2
|
+
|
|
3
|
+
Universal **movie** capture harness for Astroshots. One session API, many
|
|
4
|
+
sources, one `.astroshot/` sink (poster PNG + video + manifest).
|
|
5
|
+
|
|
6
|
+
Also exposed as **`astroshot movie`** from `@archastro/astroshot` (same binary
|
|
7
|
+
agents already install for stills).
|
|
8
|
+
|
|
9
|
+
| Source | Status | Notes |
|
|
10
|
+
|--------|--------|-------|
|
|
11
|
+
| `frames` | **ready** | Push any PNG/JPEG sequence (multi-process CLI) |
|
|
12
|
+
| `browser` | **ready** | Playwright `recordVideo` (headless OK) |
|
|
13
|
+
| `pty` | **ready** | Truecolor SGR → xterm → Chromium samples |
|
|
14
|
+
| `pty-demo` | **ready** | Truecolor smoke without a real program |
|
|
15
|
+
| `desktop.window` | **ready** (macOS) | OS `screencapture -l` + shipped Swift window list |
|
|
16
|
+
| `desktop.display` | stub | Use `desktop.window` or `frames` for now |
|
|
17
|
+
|
|
18
|
+
## Which source? (for agents)
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
astroshot movie which-source "record a ratatui TUI with truecolor"
|
|
22
|
+
astroshot movie which-source "SwiftUI onboarding window"
|
|
23
|
+
astroshot movie help-sources
|
|
24
|
+
astroshot movie --help # full decision table
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Hard rules:
|
|
28
|
+
|
|
29
|
+
1. **TUI/CLI color** → `pty` (never `desktop.window` on Terminal/iTerm/Ghostty)
|
|
30
|
+
2. **Web** → `browser` (not a desktop grab of Chrome)
|
|
31
|
+
3. **Native Mac app** → `desktop.window` (`--bundle-id` / `--window-id`)
|
|
32
|
+
4. **Custom pixels** → `frames`
|
|
33
|
+
|
|
34
|
+
## Why
|
|
35
|
+
|
|
36
|
+
Stills already stream through `.astroshot/`. Journeys need movies. Browser and
|
|
37
|
+
PTY are off-screen (headless Chromium). Native desktop is a separate adapter
|
|
38
|
+
behind the same session contract.
|
|
39
|
+
|
|
40
|
+
**PTY color is sacred:** `TERM=xterm-256color`, `COLORTERM=truecolor`, cell paint
|
|
41
|
+
via xterm (RGB / 256 / 16). Never screenshot Terminal.app.
|
|
42
|
+
|
|
43
|
+
## CLI
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# From monorepo after build:
|
|
47
|
+
node packages/movie-harness/bin/astroshot-movie.mjs --help
|
|
48
|
+
|
|
49
|
+
# Synthetic frames demo → .astroshot/demo-journey/
|
|
50
|
+
astroshot-movie run --source frames --feature demo-journey --slug flow
|
|
51
|
+
|
|
52
|
+
# Browser journey
|
|
53
|
+
astroshot-movie run --source browser --feature web --slug home \
|
|
54
|
+
--url https://example.com --settle-ms 500
|
|
55
|
+
|
|
56
|
+
# Truecolor terminal smoke (no fixture program)
|
|
57
|
+
astroshot movie run --source pty-demo --feature tui --slug brand
|
|
58
|
+
|
|
59
|
+
# Real PTY fixture
|
|
60
|
+
astroshot movie run --source pty --feature tui --slug flow \
|
|
61
|
+
--fixture ./fixtures/demo.pty.yaml
|
|
62
|
+
|
|
63
|
+
# Native macOS window (uses /usr/sbin/screencapture — already on the Mac)
|
|
64
|
+
astroshot movie list-windows
|
|
65
|
+
astroshot movie run --source desktop.window --feature app --slug onboard \
|
|
66
|
+
--bundle-id com.example.App --duration-ms 4000 --fps 10
|
|
67
|
+
|
|
68
|
+
# Multi-process frames
|
|
69
|
+
astroshot movie start --feature custom --slug walk
|
|
70
|
+
astroshot movie push-frame --feature custom --file ./frame.png
|
|
71
|
+
astroshot movie mark --feature custom --slug step-2
|
|
72
|
+
astroshot movie stop --feature custom --status pass
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`desktop.window` needs **Screen Recording** permission for your terminal/IDE
|
|
76
|
+
(System Settings → Privacy & Security). Window listing uses Swift
|
|
77
|
+
(`native/macos/WindowTools.swift`) shipped in the package — no extra download.
|
|
78
|
+
|
|
79
|
+
## Library
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { MovieSession, encodeSolidPng, recordBrowserMovie } from "@archastro/movie-harness";
|
|
83
|
+
|
|
84
|
+
const session = MovieSession.create({
|
|
85
|
+
feature: "install-wizard",
|
|
86
|
+
slug: "journey",
|
|
87
|
+
source: "frames",
|
|
88
|
+
size: { width: 1280, height: 720 },
|
|
89
|
+
fps: 15,
|
|
90
|
+
});
|
|
91
|
+
session.pushFrame(encodeSolidPng(1280, 720, [124, 92, 255]));
|
|
92
|
+
session.mark("purple");
|
|
93
|
+
const artifact = await session.stop({ status: "pass" });
|
|
94
|
+
// artifact.posterPath, artifact.videoPath under .astroshot/
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Write contract
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
.astroshot/<feature>/
|
|
101
|
+
manifest.json
|
|
102
|
+
0001-journey.png # poster (tray / overlay today)
|
|
103
|
+
0001-journey.webm # movie
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Manifest shot fields (extension):
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"kind": "movie",
|
|
111
|
+
"file": "0001-journey.png",
|
|
112
|
+
"video": "0001-journey.webm",
|
|
113
|
+
"duration_ms": 1200,
|
|
114
|
+
"source": "browser",
|
|
115
|
+
"chapters": [{ "slug": "mid", "t_ms": 400 }]
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Phase 0: Astroshots app needs no changes (poster is a normal image).
|
|
120
|
+
Phase 1: open `video` from detail when present.
|
|
121
|
+
|
|
122
|
+
## Encode
|
|
123
|
+
|
|
124
|
+
1. **ffmpeg** if on `PATH` (WebM VP9 or MP4 H.264)
|
|
125
|
+
2. Else **Playwright** slideshow → `recordVideo` WebM (no extra native deps)
|
|
126
|
+
|
|
127
|
+
## Desktop window (macOS)
|
|
128
|
+
|
|
129
|
+
Uses tooling already on the machine:
|
|
130
|
+
|
|
131
|
+
1. **Window list** — shipped `WindowTools.swift` via `swift` (Xcode CLT)
|
|
132
|
+
2. **Frames** — `/usr/sbin/screencapture -l <windowId>`
|
|
133
|
+
3. **Encode** — same Playwright/ffmpeg path as other sources
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
astroshot movie run --source desktop.window --feature app --slug flow \
|
|
137
|
+
--bundle-id ai.archastro.Astroshots --duration-ms 3000
|
|
138
|
+
```
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function runCli(argv: string[]): Promise<number>;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { encodeSolidPng } from "./png.js";
|
|
4
|
+
import { resolveRoot } from "./paths.js";
|
|
5
|
+
import { finalizeManifest } from "./sink.js";
|
|
6
|
+
import { formatSourceCatalog, recommendSource, SOURCE_DECISION_TABLE, sourceHintForError, } from "./source-help.js";
|
|
7
|
+
import { recordBrowserMovie } from "./sources/browser.js";
|
|
8
|
+
import { assertDesktopToolchain, checkScreenRecordingAccess, desktopMatchFromFlags, listDesktopWindows, openScreenRecordingSettings, recordDesktopWindowMovie, } from "./sources/desktop-macos.js";
|
|
9
|
+
import { loadFrameSession, markFrameSession, pushFrameToSession, startFrameSession, stopFrameSession, } from "./sources/frames-store.js";
|
|
10
|
+
import { recordPtyMovie, recordTruecolorDemoMovie, } from "./sources/pty.js";
|
|
11
|
+
function usage() {
|
|
12
|
+
return `astroshot movie — universal movie harness → .astroshot/ poster + video
|
|
13
|
+
|
|
14
|
+
${SOURCE_DECISION_TABLE}
|
|
15
|
+
|
|
16
|
+
Commands
|
|
17
|
+
--------
|
|
18
|
+
which-source [intent…] Recommend a --source for an agent/human intent
|
|
19
|
+
list-windows List macOS windows (id, bundle, title) for desktop.window
|
|
20
|
+
check-screen-access Detect Screen Recording TCC (optional --request prompt)
|
|
21
|
+
open-screen-settings Open System Settings → Screen Recording
|
|
22
|
+
start / push-frame / mark / stop
|
|
23
|
+
Multi-process frames session
|
|
24
|
+
run One-shot recorder (browser | pty | desktop.window | frames | …)
|
|
25
|
+
finalize Set manifest status pass|fail|idle
|
|
26
|
+
|
|
27
|
+
run --source …
|
|
28
|
+
--------------
|
|
29
|
+
browser --url URL | --script path.mjs [--headed] [--settle-ms N]
|
|
30
|
+
pty --fixture path.yaml|json # truecolor TUI — preferred for terminals
|
|
31
|
+
pty-demo [--color 7c5cff] # truecolor smoke, no program
|
|
32
|
+
desktop.window --bundle-id ID | --window-id N | --title-regex RE | --owner NAME | --pid N
|
|
33
|
+
[--duration-ms 3000] [--fps 10] [--cursor]
|
|
34
|
+
frames [--demo-frames N] # or use start/push-frame/stop
|
|
35
|
+
|
|
36
|
+
Common options
|
|
37
|
+
--------------
|
|
38
|
+
--feature NAME kebab-case feature dir under .astroshot/ (required for capture)
|
|
39
|
+
--slug SLUG kebab-case movie slug (required for capture)
|
|
40
|
+
--root DIR worktree root (default: git root or cwd)
|
|
41
|
+
--run-id ID stable run identity (share across movies in one journey)
|
|
42
|
+
--title TEXT human title
|
|
43
|
+
--description T what the movie proves
|
|
44
|
+
--size WxH encode size (desktop defaults to window pixels)
|
|
45
|
+
--fps N default 15 (desktop default 10)
|
|
46
|
+
--format webm|mp4
|
|
47
|
+
--status running|pass|fail|idle
|
|
48
|
+
--session ID frames session id (default: latest)
|
|
49
|
+
-h, --help
|
|
50
|
+
|
|
51
|
+
Agent notes
|
|
52
|
+
-----------
|
|
53
|
+
• Prefer the decision table over improvising OS screen recording for web/TUI.
|
|
54
|
+
• Always land poster+video under .astroshot/ so Astroshots streams posters.
|
|
55
|
+
• desktop.window uses macOS screencapture (already on the system) + Swift window list
|
|
56
|
+
shipped in this package — no extra binary download. Needs Screen Recording TCC.
|
|
57
|
+
• For full catalog: astroshot movie help-sources
|
|
58
|
+
|
|
59
|
+
Examples
|
|
60
|
+
--------
|
|
61
|
+
astroshot movie which-source "record a ratatui TUI with truecolor"
|
|
62
|
+
astroshot movie run --source browser --feature web --slug home --url https://example.com
|
|
63
|
+
astroshot movie run --source pty --feature tui --slug flow --fixture ./flow.pty.yaml
|
|
64
|
+
astroshot movie run --source desktop.window --feature app --slug onboard \\
|
|
65
|
+
--bundle-id com.example.App --duration-ms 4000
|
|
66
|
+
astroshot movie list-windows
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
function parseArgs(argv) {
|
|
70
|
+
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
|
|
71
|
+
return { command: "help", args: {}, rest: [] };
|
|
72
|
+
}
|
|
73
|
+
const command = argv[0];
|
|
74
|
+
const args = {};
|
|
75
|
+
const rest = [];
|
|
76
|
+
for (let i = 1; i < argv.length; i++) {
|
|
77
|
+
const token = argv[i];
|
|
78
|
+
if (token === "-h" || token === "--help") {
|
|
79
|
+
args.help = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!token.startsWith("--")) {
|
|
83
|
+
rest.push(token);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const key = token.slice(2);
|
|
87
|
+
const next = argv[i + 1];
|
|
88
|
+
if (next === undefined || next.startsWith("--")) {
|
|
89
|
+
args[key] = true;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
args[key] = next;
|
|
93
|
+
i += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { command, args, rest };
|
|
97
|
+
}
|
|
98
|
+
function req(args, key) {
|
|
99
|
+
const value = args[key];
|
|
100
|
+
if (typeof value !== "string" || !value) {
|
|
101
|
+
throw new Error(`--${key} is required`);
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
function opt(args, key) {
|
|
106
|
+
const value = args[key];
|
|
107
|
+
return typeof value === "string" ? value : undefined;
|
|
108
|
+
}
|
|
109
|
+
function parseSize(value) {
|
|
110
|
+
if (!value)
|
|
111
|
+
return undefined;
|
|
112
|
+
const match = /^(\d+)x(\d+)$/.exec(value);
|
|
113
|
+
if (!match)
|
|
114
|
+
throw new Error(`--size must be WxH, got ${value}`);
|
|
115
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
116
|
+
}
|
|
117
|
+
function parseFormat(value) {
|
|
118
|
+
if (!value)
|
|
119
|
+
return undefined;
|
|
120
|
+
if (value !== "webm" && value !== "mp4") {
|
|
121
|
+
throw new Error(`--format must be webm|mp4`);
|
|
122
|
+
}
|
|
123
|
+
return value;
|
|
124
|
+
}
|
|
125
|
+
function parseFps(value) {
|
|
126
|
+
if (!value)
|
|
127
|
+
return undefined;
|
|
128
|
+
const n = Number(value);
|
|
129
|
+
if (!(n > 0 && n <= 60))
|
|
130
|
+
throw new Error(`--fps must be in (0, 60]`);
|
|
131
|
+
return n;
|
|
132
|
+
}
|
|
133
|
+
export async function runCli(argv) {
|
|
134
|
+
try {
|
|
135
|
+
const { command, args, rest } = parseArgs(argv);
|
|
136
|
+
if (command === "help" || args.help) {
|
|
137
|
+
process.stdout.write(`${usage()}\n`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
switch (command) {
|
|
141
|
+
case "which-source":
|
|
142
|
+
return cmdWhichSource(rest, args);
|
|
143
|
+
case "help-sources":
|
|
144
|
+
process.stdout.write(`${formatSourceCatalog()}\n`);
|
|
145
|
+
return 0;
|
|
146
|
+
case "list-windows":
|
|
147
|
+
return cmdListWindows();
|
|
148
|
+
case "check-screen-access":
|
|
149
|
+
return cmdCheckScreenAccess(args);
|
|
150
|
+
case "open-screen-settings":
|
|
151
|
+
return cmdOpenScreenSettings();
|
|
152
|
+
case "start":
|
|
153
|
+
return cmdStart(args);
|
|
154
|
+
case "push-frame":
|
|
155
|
+
return cmdPushFrame(args);
|
|
156
|
+
case "mark":
|
|
157
|
+
return cmdMark(args);
|
|
158
|
+
case "stop":
|
|
159
|
+
return await cmdStop(args);
|
|
160
|
+
case "run":
|
|
161
|
+
return await cmdRun(args);
|
|
162
|
+
case "finalize":
|
|
163
|
+
return cmdFinalize(args);
|
|
164
|
+
default:
|
|
165
|
+
throw new Error(`unknown command: ${command}\n\n${usage()}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
process.stderr.write(`error: ${message}\n`);
|
|
171
|
+
if (!/which-source|decision table|list-windows/i.test(message)) {
|
|
172
|
+
process.stderr.write(`hint: ${sourceHintForError()}\n`);
|
|
173
|
+
}
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function cmdWhichSource(rest, args) {
|
|
178
|
+
const intent = rest.join(" ") ||
|
|
179
|
+
opt(args, "intent") ||
|
|
180
|
+
opt(args, "for") ||
|
|
181
|
+
"";
|
|
182
|
+
if (!intent) {
|
|
183
|
+
process.stdout.write(`${SOURCE_DECISION_TABLE}\n\n` +
|
|
184
|
+
`Pass free text, e.g.:\n` +
|
|
185
|
+
` astroshot movie which-source "native SwiftUI onboarding window"\n` +
|
|
186
|
+
` astroshot movie which-source "ratatui truecolor dashboard"\n`);
|
|
187
|
+
return 0;
|
|
188
|
+
}
|
|
189
|
+
const advice = recommendSource(intent);
|
|
190
|
+
process.stdout.write(`${JSON.stringify({ intent, recommended: advice.source, reason: advice.reason }, null, 2)}\n`);
|
|
191
|
+
return 0;
|
|
192
|
+
}
|
|
193
|
+
function cmdListWindows() {
|
|
194
|
+
assertDesktopToolchain();
|
|
195
|
+
const windows = listDesktopWindows();
|
|
196
|
+
process.stdout.write(`${JSON.stringify(windows, null, 2)}\n`);
|
|
197
|
+
process.stderr.write(`# ${windows.length} windows — match with --window-id / --bundle-id / --title-regex / --owner / --pid\n`);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
function cmdCheckScreenAccess(args) {
|
|
201
|
+
assertDesktopToolchain();
|
|
202
|
+
const request = Boolean(args.request);
|
|
203
|
+
const report = checkScreenRecordingAccess({ request });
|
|
204
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
205
|
+
if (!report.granted) {
|
|
206
|
+
process.stderr.write(`\nScreen Recording: DENIED (enable "${report.enableApp}")\n` +
|
|
207
|
+
` → ${report.settingsHint}\n` +
|
|
208
|
+
` → or: astroshot movie open-screen-settings\n` +
|
|
209
|
+
` → then quit & reopen ${report.enableApp}, re-run check-screen-access\n`);
|
|
210
|
+
if (args["open-settings"] || args.open) {
|
|
211
|
+
openScreenRecordingSettings();
|
|
212
|
+
}
|
|
213
|
+
return 2;
|
|
214
|
+
}
|
|
215
|
+
process.stderr.write(`# Screen Recording preflight: OK — still enable "${report.enableApp}" in Settings if captures fail\n`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
function cmdOpenScreenSettings() {
|
|
219
|
+
assertDesktopToolchain();
|
|
220
|
+
const ok = openScreenRecordingSettings();
|
|
221
|
+
if (!ok) {
|
|
222
|
+
process.stderr.write("error: could not open System Settings; open Privacy & Security → Screen Recording manually\n");
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
process.stderr.write("Opened System Settings (Screen Recording). Enable the app that launched this command, quit it fully, reopen, then:\n" +
|
|
226
|
+
" astroshot movie check-screen-access\n");
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
function cmdStart(args) {
|
|
230
|
+
const state = startFrameSession({
|
|
231
|
+
feature: req(args, "feature"),
|
|
232
|
+
slug: req(args, "slug"),
|
|
233
|
+
root: opt(args, "root"),
|
|
234
|
+
runId: opt(args, "run-id"),
|
|
235
|
+
title: opt(args, "title"),
|
|
236
|
+
description: opt(args, "description"),
|
|
237
|
+
size: parseSize(opt(args, "size")),
|
|
238
|
+
fps: parseFps(opt(args, "fps")),
|
|
239
|
+
format: parseFormat(opt(args, "format")),
|
|
240
|
+
});
|
|
241
|
+
process.stdout.write(`${JSON.stringify({
|
|
242
|
+
id: state.id,
|
|
243
|
+
feature: state.feature,
|
|
244
|
+
slug: state.slug,
|
|
245
|
+
runId: state.runId,
|
|
246
|
+
frameDir: state.frameDir,
|
|
247
|
+
}, null, 2)}\n`);
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
function cmdPushFrame(args) {
|
|
251
|
+
const state = loadFrameSession(opt(args, "root") ?? resolveRoot(), req(args, "feature"), opt(args, "session"));
|
|
252
|
+
const file = req(args, "file");
|
|
253
|
+
const next = pushFrameToSession(state, path.resolve(file));
|
|
254
|
+
process.stdout.write(`${JSON.stringify({ id: next.id, frameCount: next.frameCount }, null, 2)}\n`);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
function cmdMark(args) {
|
|
258
|
+
const state = loadFrameSession(opt(args, "root") ?? resolveRoot(), req(args, "feature"), opt(args, "session"));
|
|
259
|
+
const next = markFrameSession(state, req(args, "slug"), opt(args, "note"));
|
|
260
|
+
process.stdout.write(`${JSON.stringify({ id: next.id, chapters: next.chapters }, null, 2)}\n`);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
async function cmdStop(args) {
|
|
264
|
+
const state = loadFrameSession(opt(args, "root") ?? resolveRoot(), req(args, "feature"), opt(args, "session"));
|
|
265
|
+
const status = (opt(args, "status") ?? "running");
|
|
266
|
+
const artifact = await stopFrameSession(state, { status });
|
|
267
|
+
process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
|
|
268
|
+
return 0;
|
|
269
|
+
}
|
|
270
|
+
async function cmdRun(args) {
|
|
271
|
+
const source = req(args, "source");
|
|
272
|
+
const feature = req(args, "feature");
|
|
273
|
+
const slug = req(args, "slug");
|
|
274
|
+
const common = {
|
|
275
|
+
feature,
|
|
276
|
+
slug,
|
|
277
|
+
root: opt(args, "root"),
|
|
278
|
+
runId: opt(args, "run-id"),
|
|
279
|
+
title: opt(args, "title"),
|
|
280
|
+
description: opt(args, "description"),
|
|
281
|
+
size: parseSize(opt(args, "size")),
|
|
282
|
+
fps: parseFps(opt(args, "fps")),
|
|
283
|
+
format: parseFormat(opt(args, "format")),
|
|
284
|
+
status: (opt(args, "status") ?? "running"),
|
|
285
|
+
};
|
|
286
|
+
if (source === "frames") {
|
|
287
|
+
const count = Number(opt(args, "demo-frames") ?? "8");
|
|
288
|
+
const size = common.size ?? { width: 320, height: 180 };
|
|
289
|
+
let state = startFrameSession({ ...common, size });
|
|
290
|
+
const tmpDir = path.join(state.frameDir, "..", "demo-src");
|
|
291
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
292
|
+
const colors = [
|
|
293
|
+
[124, 92, 255],
|
|
294
|
+
[90, 200, 250],
|
|
295
|
+
[80, 220, 160],
|
|
296
|
+
[240, 180, 110],
|
|
297
|
+
[240, 114, 122],
|
|
298
|
+
[232, 232, 242],
|
|
299
|
+
[90, 90, 122],
|
|
300
|
+
[9, 10, 18],
|
|
301
|
+
];
|
|
302
|
+
for (let i = 0; i < count; i++) {
|
|
303
|
+
const rgb = colors[i % colors.length];
|
|
304
|
+
const png = encodeSolidPng(size.width, size.height, rgb);
|
|
305
|
+
const file = path.join(tmpDir, `f-${i}.png`);
|
|
306
|
+
fs.writeFileSync(file, png);
|
|
307
|
+
state = pushFrameToSession(state, file);
|
|
308
|
+
if (i === Math.floor(count / 2)) {
|
|
309
|
+
state = markFrameSession(state, "midpoint");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const artifact = await stopFrameSession(state, {
|
|
313
|
+
status: common.status,
|
|
314
|
+
});
|
|
315
|
+
process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
if (source === "browser") {
|
|
319
|
+
const artifact = await recordBrowserMovie({
|
|
320
|
+
...common,
|
|
321
|
+
url: opt(args, "url"),
|
|
322
|
+
scriptPath: opt(args, "script"),
|
|
323
|
+
headed: Boolean(args.headed),
|
|
324
|
+
settleMs: opt(args, "settle-ms")
|
|
325
|
+
? Number(opt(args, "settle-ms"))
|
|
326
|
+
: undefined,
|
|
327
|
+
});
|
|
328
|
+
process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
if (source === "pty") {
|
|
332
|
+
const artifact = await recordPtyMovie({
|
|
333
|
+
...common,
|
|
334
|
+
fixturePath: req(args, "fixture"),
|
|
335
|
+
});
|
|
336
|
+
process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
if (source === "pty-demo") {
|
|
340
|
+
const artifact = await recordTruecolorDemoMovie({
|
|
341
|
+
...common,
|
|
342
|
+
color: opt(args, "color"),
|
|
343
|
+
});
|
|
344
|
+
process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
if (source === "desktop.window") {
|
|
348
|
+
assertDesktopToolchain();
|
|
349
|
+
const match = desktopMatchFromFlags({
|
|
350
|
+
"window-id": opt(args, "window-id"),
|
|
351
|
+
"bundle-id": opt(args, "bundle-id"),
|
|
352
|
+
"title-regex": opt(args, "title-regex"),
|
|
353
|
+
owner: opt(args, "owner"),
|
|
354
|
+
pid: opt(args, "pid"),
|
|
355
|
+
pick: opt(args, "pick"),
|
|
356
|
+
});
|
|
357
|
+
const durationMs = opt(args, "duration-ms")
|
|
358
|
+
? Number(opt(args, "duration-ms"))
|
|
359
|
+
: 3_000;
|
|
360
|
+
const artifact = await recordDesktopWindowMovie({
|
|
361
|
+
...common,
|
|
362
|
+
match,
|
|
363
|
+
durationMs,
|
|
364
|
+
cursor: Boolean(args.cursor),
|
|
365
|
+
fps: common.fps ?? 10,
|
|
366
|
+
});
|
|
367
|
+
process.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
if (source === "desktop.display" || source === "desktop.region") {
|
|
371
|
+
throw new Error(`${source} is not implemented yet.\n` +
|
|
372
|
+
`Use --source desktop.window for a single app, or --source frames.\n` +
|
|
373
|
+
sourceHintForError("desktop.window"));
|
|
374
|
+
}
|
|
375
|
+
throw new Error(`unknown --source ${JSON.stringify(source)}\n${SOURCE_DECISION_TABLE}`);
|
|
376
|
+
}
|
|
377
|
+
function cmdFinalize(args) {
|
|
378
|
+
const root = resolveRoot(opt(args, "root"));
|
|
379
|
+
const feature = req(args, "feature");
|
|
380
|
+
const runId = req(args, "run-id");
|
|
381
|
+
const status = req(args, "status");
|
|
382
|
+
if (!["pass", "fail", "idle", "running"].includes(status)) {
|
|
383
|
+
throw new Error("--status must be pass|fail|idle|running");
|
|
384
|
+
}
|
|
385
|
+
finalizeManifest(root, feature, runId, status);
|
|
386
|
+
process.stdout.write(`${JSON.stringify({ feature, runId, status }, null, 2)}\n`);
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
package/dist/encode.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EncodeFramesRequest } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Encode a PNG sequence to WebM/MP4.
|
|
4
|
+
*
|
|
5
|
+
* Prefer ffmpeg when present (fast, high quality). Otherwise replay frames in
|
|
6
|
+
* headless Chromium with Playwright recordVideo — no native deps beyond the
|
|
7
|
+
* Chromium we already ship for stills.
|
|
8
|
+
*/
|
|
9
|
+
export declare function encodeFrames(request: EncodeFramesRequest): Promise<{
|
|
10
|
+
videoPath: string;
|
|
11
|
+
durationMs: number;
|
|
12
|
+
}>;
|
|
13
|
+
/** Copy the last frame as the poster PNG. */
|
|
14
|
+
export declare function posterFromFrames(framePaths: string[], outPath: string): string;
|