automify 0.1.11 → 0.3.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.
@@ -38,6 +38,8 @@ export const AUTOMIFY_OPTION_KEYS = new Set([
38
38
  "finalScreenshot",
39
39
  "actionScreenshots",
40
40
  "screenshots",
41
+ "recording",
42
+ "screenRecording",
41
43
  "trace",
42
44
  "silent",
43
45
  "debug",
@@ -229,6 +231,8 @@ export const DO_OPTION_KEYS = new Set([
229
231
  "finalScreenshot",
230
232
  "actionScreenshots",
231
233
  "screenshots",
234
+ "recording",
235
+ "screenRecording",
232
236
  "screenshot",
233
237
  "trace",
234
238
  "silent",
@@ -313,7 +317,8 @@ export function pickKnownOptions(options, allowedKeys) {
313
317
 
314
318
  export function normalizeAutomifyOptions(options = {}) {
315
319
  assertKnownOptions("Automify", options, AUTOMIFY_OPTION_KEYS);
316
- const { viewport, limits, request, safety, hooks, screenshots, screenshot, ...rest } = options;
320
+ const { viewport, limits, request, safety, hooks, screenshots, screenshot, recording, screenRecording, ...rest } =
321
+ options;
317
322
  const viewportOptions = viewport ?? {};
318
323
  const limitOptions = limits ?? {};
319
324
  const safetyOptions = safety ?? {};
@@ -336,6 +341,8 @@ export function normalizeAutomifyOptions(options = {}) {
336
341
  initialScreenshot: rest.initialScreenshot ?? screenshotPaths.initial,
337
342
  finalScreenshot: rest.finalScreenshot ?? screenshotPaths.final,
338
343
  actionScreenshots: rest.actionScreenshots ?? screenshotPaths.actions ?? screenshotPaths.actionScreenshots,
344
+ screenRecording:
345
+ rest.screenRecording ?? screenRecording ?? rest.recording ?? recording ?? screenshotPaths.recording,
339
346
  screenshotDetail: rest.screenshotDetail ?? screenshotOptions.detail,
340
347
  screenshotMaxWidth: rest.screenshotMaxWidth ?? screenshotOptions.maxWidth ?? screenshotOptions.screenshotMaxWidth,
341
348
  screenshotMaxHeight:
@@ -347,7 +354,20 @@ export function normalizeAutomifyOptions(options = {}) {
347
354
  }
348
355
 
349
356
  function normalizeDoOptionAliases(options) {
350
- const { evaluate, limits, request, safety, hooks, screenshots, screenshot, command, commands, ...rest } = options;
357
+ const {
358
+ evaluate,
359
+ limits,
360
+ request,
361
+ safety,
362
+ hooks,
363
+ screenshots,
364
+ screenshot,
365
+ recording,
366
+ screenRecording,
367
+ command,
368
+ commands,
369
+ ...rest
370
+ } = options;
351
371
 
352
372
  const commandOptions = commands ?? command;
353
373
  assertKnownOptions("do() command", commandOptions, COMMAND_OPTION_KEYS);
@@ -364,6 +384,7 @@ function normalizeDoOptionAliases(options) {
364
384
  initialScreenshot: rest.initialScreenshot ?? screenshots?.initial,
365
385
  finalScreenshot: rest.finalScreenshot ?? screenshots?.final,
366
386
  actionScreenshots: rest.actionScreenshots ?? screenshots?.actions ?? screenshots?.actionScreenshots,
387
+ screenRecording: rest.screenRecording ?? screenRecording ?? rest.recording ?? recording ?? screenshots?.recording,
367
388
  screenshotDetail: rest.screenshotDetail ?? screenshot?.detail,
368
389
  screenshotMaxWidth: rest.screenshotMaxWidth ?? screenshot?.maxWidth ?? screenshot?.screenshotMaxWidth,
369
390
  screenshotMaxHeight: rest.screenshotMaxHeight ?? screenshot?.maxHeight ?? screenshot?.screenshotMaxHeight,
@@ -0,0 +1,184 @@
1
+ import { execFile as execFileCallback } from "node:child_process";
2
+ import { mkdtemp, mkdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { promisify } from "node:util";
6
+
7
+ import { AutomifyError } from "./errors.js";
8
+
9
+ const execFileAsync = promisify(execFileCallback);
10
+ const DEFAULT_RECORDING_FPS = 4;
11
+ const DEFAULT_RECORDING_TIMEOUT_MS = 120_000;
12
+
13
+ export async function startScreenRecording(input, context = {}) {
14
+ const options = normalizeScreenRecordingOptions(input, context);
15
+ if (!options) return null;
16
+
17
+ await mkdir(dirname(options.path), { recursive: true });
18
+ const framesDir =
19
+ options.framesDir ??
20
+ (await mkdtemp(join(tmpdir(), `automify-recording-${Date.now()}-${Math.random().toString(16).slice(2)}-`)));
21
+ await mkdir(framesDir, { recursive: true });
22
+
23
+ let frameCount = 0;
24
+ let stopped = false;
25
+ let captureTimer = null;
26
+ let inFlight = Promise.resolve();
27
+
28
+ const capture = async (force = false) => {
29
+ if (stopped && !force) return;
30
+ const frameNumber = frameCount + 1;
31
+ const screenshot = await options.captureFrame({
32
+ ...context,
33
+ recording: true,
34
+ frame: frameNumber
35
+ });
36
+ await writeFile(join(framesDir, `frame-${padFrame(frameNumber)}.png`), screenshotToBuffer(screenshot));
37
+ frameCount = frameNumber;
38
+ };
39
+
40
+ const schedule = () => {
41
+ captureTimer = setTimeout(() => {
42
+ inFlight = inFlight.then(capture).catch((error) => {
43
+ stopped = true;
44
+ throw error;
45
+ });
46
+ if (!stopped) schedule();
47
+ }, options.intervalMs);
48
+ };
49
+
50
+ inFlight = capture();
51
+ schedule();
52
+
53
+ return {
54
+ path: options.path,
55
+ framesDir,
56
+ fps: options.fps,
57
+ startedAt: new Date().toISOString(),
58
+ async stop(stopContext = {}) {
59
+ if (stopped && stopContext.force !== true) return null;
60
+ stopped = true;
61
+ clearTimeout(captureTimer);
62
+ await inFlight;
63
+ if (frameCount === 0) {
64
+ await capture(true);
65
+ }
66
+
67
+ await encodeScreenRecording({
68
+ ...options,
69
+ framesDir
70
+ });
71
+
72
+ const size = await stat(options.path).catch(() => null);
73
+ if (options.keepFrames !== true) {
74
+ await rm(framesDir, { recursive: true, force: true }).catch(() => {});
75
+ }
76
+
77
+ return {
78
+ path: options.path,
79
+ bytes: size?.size,
80
+ frames: frameCount,
81
+ fps: options.fps,
82
+ startedAt: this.startedAt,
83
+ stoppedAt: new Date().toISOString()
84
+ };
85
+ }
86
+ };
87
+ }
88
+
89
+ export function normalizeScreenRecordingOptions(input, context = {}) {
90
+ if (input == null || input === false) return null;
91
+
92
+ const raw =
93
+ input === true || typeof input === "string"
94
+ ? {
95
+ path: typeof input === "string" ? input : undefined
96
+ }
97
+ : input;
98
+
99
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
100
+ throw new AutomifyError("screenRecording must be true, a file path, or an options object.");
101
+ }
102
+ if (raw.enabled === false) return null;
103
+
104
+ const fps = positiveNumber(raw.fps) ?? DEFAULT_RECORDING_FPS;
105
+ const intervalMs = positiveNumber(raw.intervalMs ?? raw.captureIntervalMs) ?? Math.max(1, Math.round(1000 / fps));
106
+ const captureFrame = raw.captureFrame ?? context.captureFrame;
107
+ if (typeof captureFrame !== "function") {
108
+ throw new AutomifyError("screenRecording requires a captureFrame function.");
109
+ }
110
+
111
+ return {
112
+ ...raw,
113
+ path: normalizeRecordingPath(raw.path),
114
+ framesDir: normalizeOptionalPath(raw.framesDir),
115
+ fps,
116
+ intervalMs,
117
+ captureFrame,
118
+ keepFrames: raw.keepFrames === true,
119
+ execFile: raw.execFile ?? execFileAsync,
120
+ ffmpegCommand: raw.ffmpegCommand ?? raw.command ?? "ffmpeg",
121
+ encodingTimeoutMs: positiveNumber(raw.encodingTimeoutMs ?? raw.timeoutMs) ?? DEFAULT_RECORDING_TIMEOUT_MS
122
+ };
123
+ }
124
+
125
+ async function encodeScreenRecording(options) {
126
+ const outputPattern = join(options.framesDir, "frame-%06d.png");
127
+ const args = [
128
+ "-y",
129
+ "-framerate",
130
+ String(options.fps),
131
+ "-i",
132
+ outputPattern,
133
+ "-vf",
134
+ `fps=${options.fps},format=yuv420p`,
135
+ options.path
136
+ ];
137
+
138
+ try {
139
+ await options.execFile(options.ffmpegCommand, args, {
140
+ timeout: options.encodingTimeoutMs
141
+ });
142
+ } catch (error) {
143
+ throw new AutomifyError(
144
+ `Unable to encode screen recording with ${options.ffmpegCommand}. Install ffmpeg or pass screenRecording.execFile/ffmpegCommand.`,
145
+ { cause: error }
146
+ );
147
+ }
148
+ }
149
+
150
+ function normalizeRecordingPath(path) {
151
+ if (path == null || path === true) {
152
+ return join(tmpdir(), `automify-recording-${Date.now()}-${Math.random().toString(16).slice(2)}.mp4`);
153
+ }
154
+ if (typeof path !== "string" || path.trim() === "") {
155
+ throw new AutomifyError("screenRecording.path must be a non-empty file path.");
156
+ }
157
+ return path;
158
+ }
159
+
160
+ function normalizeOptionalPath(path) {
161
+ if (path == null) return undefined;
162
+ if (typeof path !== "string" || path.trim() === "") {
163
+ throw new AutomifyError("screenRecording.framesDir must be a non-empty directory path.");
164
+ }
165
+ return path;
166
+ }
167
+
168
+ function positiveNumber(value) {
169
+ const number = Number(value);
170
+ return Number.isFinite(number) && number > 0 ? number : null;
171
+ }
172
+
173
+ function padFrame(value) {
174
+ return String(value).padStart(6, "0");
175
+ }
176
+
177
+ function screenshotToBuffer(value) {
178
+ if (typeof value === "string") {
179
+ const dataUrlMatch = /^data:[^;]+;base64,(.*)$/s.exec(value);
180
+ return Buffer.from(dataUrlMatch ? dataUrlMatch[1] : value, dataUrlMatch ? "base64" : "utf8");
181
+ }
182
+
183
+ return Buffer.from(value);
184
+ }