@loadmill/droid-cua 2.4.0 → 2.6.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.
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
+ import sharp from "sharp";
3
4
  function pad2(value) {
4
5
  return String(value).padStart(2, "0");
5
6
  }
@@ -32,6 +33,12 @@ function buildCallSegment(callId) {
32
33
  }
33
34
  return `call-${sanitizeArtifactSegment(callId, "call").slice(0, 24)}`;
34
35
  }
36
+ function buildEventSegment(eventId) {
37
+ if (typeof eventId !== "string" || !eventId.trim()) {
38
+ return null;
39
+ }
40
+ return `event-${sanitizeArtifactSegment(eventId, "event").slice(0, 36)}`;
41
+ }
35
42
  function buildScreenshotFileName(sequence, metadata = {}) {
36
43
  const segments = [
37
44
  String(sequence).padStart(4, "0"),
@@ -50,8 +57,119 @@ function buildScreenshotFileName(sequence, metadata = {}) {
50
57
  if (callSegment) {
51
58
  segments.push(callSegment);
52
59
  }
60
+ const eventSegment = buildEventSegment(metadata.eventId);
61
+ if (eventSegment) {
62
+ segments.push(eventSegment);
63
+ }
53
64
  return `${segments.join("_")}.png`;
54
65
  }
66
+ function clampCoordinate(value, max) {
67
+ if (!Number.isFinite(value) || max <= 0) {
68
+ return 0;
69
+ }
70
+ return Math.max(0, Math.min(max - 1, Math.round(value)));
71
+ }
72
+ function buildMarkerPlacement(marker = {}, width, height) {
73
+ const requestedX = Number(marker.x);
74
+ const requestedY = Number(marker.y);
75
+ const x = clampCoordinate(requestedX, width);
76
+ const y = clampCoordinate(requestedY, height);
77
+ return {
78
+ requestedX,
79
+ requestedY,
80
+ x,
81
+ y,
82
+ wasClamped: !Number.isFinite(requestedX) || !Number.isFinite(requestedY) || x !== Math.round(requestedX) || y !== Math.round(requestedY),
83
+ };
84
+ }
85
+ const MARKER_APPEARANCES = {
86
+ click: {
87
+ haloFill: "#39ff14",
88
+ haloOpacity: 0.28,
89
+ ringStroke: "#39ff14",
90
+ centerDotFill: "#07131f",
91
+ },
92
+ preview: {
93
+ haloFill: "#00d8ff",
94
+ haloOpacity: 0.28,
95
+ ringStroke: "#00d8ff",
96
+ centerDotFill: "#07131f",
97
+ },
98
+ };
99
+ function getMarkerAppearance(marker = {}) {
100
+ if (marker?.style === "preview") {
101
+ return MARKER_APPEARANCES.preview;
102
+ }
103
+ return MARKER_APPEARANCES.click;
104
+ }
105
+ function buildClickMarkerSvg({ width, height, x, y, radius, marker }) {
106
+ const haloRadius = radius + 8;
107
+ const whiteStrokeWidth = Math.max(5, Math.round(radius * 0.34));
108
+ const ringStrokeWidth = Math.max(3, Math.round(radius * 0.18));
109
+ const centerDotRadius = Math.max(2, Math.round(radius * 0.22));
110
+ const centerDotStrokeWidth = Math.max(2, Math.round(radius * 0.1));
111
+ const appearance = getMarkerAppearance(marker);
112
+ return `
113
+ <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
114
+ <circle cx="${x}" cy="${y}" r="${haloRadius}" fill="${appearance.haloFill}" opacity="${appearance.haloOpacity}" />
115
+ <circle cx="${x}" cy="${y}" r="${radius}" fill="none" stroke="#ffffff" stroke-width="${whiteStrokeWidth}" />
116
+ <circle cx="${x}" cy="${y}" r="${radius}" fill="none" stroke="${appearance.ringStroke}" stroke-width="${ringStrokeWidth}" />
117
+ <circle cx="${x}" cy="${y}" r="${centerDotRadius}" fill="${appearance.centerDotFill}" stroke="#ffffff" stroke-width="${centerDotStrokeWidth}" />
118
+ </svg>
119
+ `.trim();
120
+ }
121
+ async function renderAnnotatedScreenshot(image, marker = {}) {
122
+ const metadata = await image.metadata();
123
+ const width = Number(metadata.width) || 0;
124
+ const height = Number(metadata.height) || 0;
125
+ if (width <= 0 || height <= 0) {
126
+ return null;
127
+ }
128
+ const placement = buildMarkerPlacement(marker, width, height);
129
+ const radius = Math.max(14, Math.round(Math.min(width, height) * 0.055));
130
+ const overlay = Buffer.from(buildClickMarkerSvg({ width, height, x: placement.x, y: placement.y, radius, marker }));
131
+ const outputBuffer = await image
132
+ .composite([{ input: overlay }])
133
+ .png()
134
+ .toBuffer();
135
+ return {
136
+ outputBuffer,
137
+ width,
138
+ height,
139
+ marker: placement,
140
+ };
141
+ }
142
+ export async function createAnnotatedScreenshotBuffer(filePath, marker = {}) {
143
+ if (typeof filePath !== "string" || !filePath.trim()) {
144
+ return null;
145
+ }
146
+ const result = await renderAnnotatedScreenshot(sharp(filePath), marker);
147
+ if (!result) {
148
+ return null;
149
+ }
150
+ return result.outputBuffer;
151
+ }
152
+ export async function createAnnotatedScreenshotFromBase64(screenshotBase64, marker = {}) {
153
+ if (typeof screenshotBase64 !== "string" || !screenshotBase64) {
154
+ return null;
155
+ }
156
+ const result = await renderAnnotatedScreenshot(sharp(Buffer.from(screenshotBase64, "base64")), marker);
157
+ if (!result) {
158
+ return null;
159
+ }
160
+ return {
161
+ ...result,
162
+ screenshotBase64: result.outputBuffer.toString("base64"),
163
+ };
164
+ }
165
+ export async function annotateScreenshotArtifact(filePath, marker = {}) {
166
+ const outputBuffer = await createAnnotatedScreenshotBuffer(filePath, marker);
167
+ if (!outputBuffer) {
168
+ return null;
169
+ }
170
+ await writeFile(filePath, outputBuffer);
171
+ return filePath;
172
+ }
55
173
  export function createDebugScreenshotRecorder({ directoryPath }) {
56
174
  let nextSequence = 1;
57
175
  let ensuredDirectoryPromise = null;
@@ -76,6 +194,23 @@ export function createDebugScreenshotRecorder({ directoryPath }) {
76
194
  const filePath = path.join(directoryPath, fileName);
77
195
  await writeFile(filePath, Buffer.from(screenshotBase64, "base64"));
78
196
  return filePath;
197
+ },
198
+ async saveAnnotatedScreenshot(sourceFilePath, marker = {}, metadata = {}) {
199
+ if (typeof sourceFilePath !== "string" || !sourceFilePath.trim()) {
200
+ return null;
201
+ }
202
+ await ensureDirectory();
203
+ const outputBuffer = await createAnnotatedScreenshotBuffer(sourceFilePath, marker);
204
+ if (!outputBuffer) {
205
+ return null;
206
+ }
207
+ const fileName = buildScreenshotFileName(nextSequence++, metadata);
208
+ const filePath = path.join(directoryPath, fileName);
209
+ await writeFile(filePath, outputBuffer);
210
+ return {
211
+ artifactPaths: [filePath],
212
+ annotationSourcePaths: [sourceFilePath],
213
+ };
79
214
  }
80
215
  };
81
216
  }
@@ -93,6 +228,32 @@ export function createCompositeScreenshotRecorder({ recorders }) {
93
228
  }
94
229
  const results = await Promise.all(activeRecorders.map((recorder) => recorder.saveScreenshot(screenshotBase64, metadata)));
95
230
  return results.filter(Boolean);
231
+ },
232
+ async saveAnnotatedScreenshot(sourceFilePath, marker = {}, metadata = {}) {
233
+ if (typeof sourceFilePath !== "string" || !sourceFilePath.trim()) {
234
+ return null;
235
+ }
236
+ const results = await Promise.all(activeRecorders.map((recorder) => recorder.saveAnnotatedScreenshot?.(sourceFilePath, marker, metadata)));
237
+ const filteredResults = results.filter(Boolean);
238
+ if (filteredResults.length === 0) {
239
+ return null;
240
+ }
241
+ return {
242
+ artifactPaths: filteredResults.flatMap((result) => {
243
+ if (result && typeof result === "object" && !Array.isArray(result)) {
244
+ return Array.isArray(result.artifactPaths) ? result.artifactPaths.filter(Boolean) : [];
245
+ }
246
+ return typeof result === "string" ? [result] : [];
247
+ }),
248
+ annotationSourcePaths: filteredResults.flatMap((result) => {
249
+ if (result && typeof result === "object" && !Array.isArray(result) && Array.isArray(result.annotationSourcePaths)) {
250
+ return result.annotationSourcePaths.filter(Boolean);
251
+ }
252
+ return [];
253
+ }),
254
+ screenshotPath: filteredResults.find((result) => result?.screenshotPath)?.screenshotPath ?? null,
255
+ screenshotDataUrl: filteredResults.find((result) => result?.screenshotDataUrl)?.screenshotDataUrl ?? null,
256
+ };
96
257
  }
97
258
  };
98
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmill/droid-cua",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "AI-powered Android testing agent using OpenAI's computer-use model and ADB",
5
5
  "main": "build/index.js",
6
6
  "type": "module",