kelra 0.0.0-stage → 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/AGENT_GUIDE.md +34 -0
- package/LICENSE +47 -0
- package/README.md +62 -2
- package/bin/kelra-ocr +0 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +34 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +3 -0
- package/dist/developer.d.ts +2 -0
- package/dist/developer.js +14 -0
- package/dist/devicectl.d.ts +5 -0
- package/dist/devicectl.js +45 -0
- package/dist/friendly.d.ts +11 -0
- package/dist/friendly.js +39 -0
- package/dist/guide.d.ts +1 -0
- package/dist/guide.js +13 -0
- package/dist/image.d.ts +27 -0
- package/dist/image.js +59 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +290 -0
- package/dist/license.d.ts +15 -0
- package/dist/license.js +29 -0
- package/dist/live.d.ts +2 -0
- package/dist/live.js +6 -0
- package/dist/login.d.ts +22 -0
- package/dist/login.js +84 -0
- package/dist/ocr.d.ts +6 -0
- package/dist/ocr.js +40 -0
- package/dist/service.d.ts +7 -0
- package/dist/service.js +32 -0
- package/dist/settle.d.ts +17 -0
- package/dist/settle.js +27 -0
- package/package.json +38 -4
- package/swift/ocr.swift +216 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// kelra-connect: the MCP server Claude Code runs on the customer's Mac.
|
|
3
|
+
// It does what needs the Mac (devices, screenshots, the clipboard, reading files, reading text, the app's live layout)
|
|
4
|
+
// and sends measurements to the kelra service, which does the analysis.
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { devicectl, screenshotPng, settledScreenshotPng } from "./devicectl.js";
|
|
14
|
+
import { readPng, shapeWarning } from "./friendly.js";
|
|
15
|
+
import { contrast, crop, decode, encode, hex, palette, parseHex, pixel, scanLine } from "./image.js";
|
|
16
|
+
import { fetchTree } from "./live.js";
|
|
17
|
+
import { readRawBuffer } from "./ocr.js";
|
|
18
|
+
import { callService } from "./service.js";
|
|
19
|
+
import { guide } from "./guide.js";
|
|
20
|
+
// The guide for AI agents (usage and how to handle the helper) is sent to the model whenever this server is connected.
|
|
21
|
+
const instructions = guide;
|
|
22
|
+
const server = new McpServer({ name: "kelra", version: "0.1.0" }, { instructions });
|
|
23
|
+
// the same guide, readable on demand
|
|
24
|
+
server.resource("kelra-guide", "kelra://guide", { mimeType: "text/markdown", description: "How to use kelra and how to handle the helper and the license" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: guide }] }));
|
|
25
|
+
const device = z.string().describe("Device name, UDID or ECID from list_devices");
|
|
26
|
+
const text = (o) => ({ content: [{ type: "text", text: JSON.stringify(o, null, 2) }] });
|
|
27
|
+
const wrap = (fn) => fn().catch((e) => ({ isError: true, content: [{ type: "text", text: e.message }] }));
|
|
28
|
+
const run = promisify(execFile);
|
|
29
|
+
const b64 = (buf) => buf.toString("base64");
|
|
30
|
+
server.tool("kelra_guide", "The kelra guide for AI agents: how to use kelra, and the rules for handling the helper and the license. Read it if you are unsure what to do when a kelra tool fails.", {}, () => wrap(async () => ({ content: [{ type: "text", text: guide }] })));
|
|
31
|
+
// ---- on the Mac: devices -------------------------------------------------------------------------------------
|
|
32
|
+
server.tool("list_devices", "List simulated and physical devices known to devicectl (Xcode Device Hub).", {}, () => wrap(async () => {
|
|
33
|
+
const r = await devicectl(["list", "devices"]);
|
|
34
|
+
return text(r.devices.map((d) => ({
|
|
35
|
+
name: d.deviceProperties?.name, id: d.hardwareProperties?.udid ?? d.identifier,
|
|
36
|
+
state: d.connectionProperties?.tunnelState, model: d.hardwareProperties?.marketingName,
|
|
37
|
+
reality: d.hardwareProperties?.reality,
|
|
38
|
+
})));
|
|
39
|
+
}));
|
|
40
|
+
server.tool("device_state", "Displays (pixel size, pointScale, orientation) plus appearance (dark/light, increased contrast, large text, Liquid Glass) for a device. Use pointScale to convert screenshot pixels to points.", { device }, ({ device }) => wrap(async () => {
|
|
41
|
+
const [displays, appearance] = await Promise.all([
|
|
42
|
+
devicectl(["device", "info", "displays", "-d", device]),
|
|
43
|
+
devicectl(["device", "info", "appearance", "-d", device]),
|
|
44
|
+
]);
|
|
45
|
+
return text({
|
|
46
|
+
displays: displays.displays.map((d) => ({
|
|
47
|
+
id: d.uniqueId, primary: d.primary, active: d.active, nativePixels: d.nativeSize,
|
|
48
|
+
pointScale: d.pointScale, orientation: d.currentOrientation,
|
|
49
|
+
sizeInPoints: d.nativeSize.map((v) => v / d.pointScale),
|
|
50
|
+
})),
|
|
51
|
+
appearance,
|
|
52
|
+
});
|
|
53
|
+
}));
|
|
54
|
+
server.tool("screenshot", "Capture the device screen. Multi-display devices (iPhone Duo) return one composite image; coordinates for the other tools are pixels in this image.", { device }, ({ device }) => wrap(async () => {
|
|
55
|
+
const buf = await screenshotPng(device);
|
|
56
|
+
const img = decode(buf);
|
|
57
|
+
return { content: [{ type: "text", text: `Screenshot ${img.width}x${img.height}px` }, { type: "image", data: b64(buf), mimeType: "image/png" }] };
|
|
58
|
+
}));
|
|
59
|
+
server.tool("set_status_bar", "Override a simulated device's status bar so it matches a reference (default: 9:41, full Wi-Fi and cellular, 100% battery). Use clear to restore the real one.", { device, clear: z.boolean().default(false), time: z.string().default("9:41"), batteryLevel: z.number().int().min(0).max(100).default(100) }, ({ device, clear, time, batteryLevel }) => wrap(async () => {
|
|
60
|
+
if (clear)
|
|
61
|
+
await devicectl(["device", "simulate", "statusBar", "clear", "-d", device]);
|
|
62
|
+
else
|
|
63
|
+
await devicectl(["device", "simulate", "statusBar", "override", "-d", device, "--time", time, "--wifi-mode", "active", "--wifi-strength", "3", "--cellular-mode", "active", "--cellular-strength", "4", "--battery-state", "draining", "--battery-level", String(batteryLevel)]);
|
|
64
|
+
return text({ ok: true, device, status: clear ? "cleared" : { time, wifi: 3, cellular: 4, batteryLevel } });
|
|
65
|
+
}));
|
|
66
|
+
// ---- on the Mac: simple pixel tools (no analysis, so they work offline) ----------------------------------------
|
|
67
|
+
server.tool("sample_color", "Exact RGB/hex of one pixel in a fresh screenshot.", { device, x: z.number().int(), y: z.number().int() }, ({ device, x, y }) => wrap(async () => {
|
|
68
|
+
const c = pixel(decode(await screenshotPng(device)), x, y);
|
|
69
|
+
return text({ x, y, rgb: c, hex: hex(c) });
|
|
70
|
+
}));
|
|
71
|
+
server.tool("measure_region", "Dominant colors of a pixel rect plus an enlarged crop image, for inspecting one component precisely.", { device, x: z.number().int(), y: z.number().int(), w: z.number().int().positive(), h: z.number().int().positive() }, ({ device, ...r }) => wrap(async () => {
|
|
72
|
+
const img = decode(await screenshotPng(device));
|
|
73
|
+
if (r.x + r.w > img.width || r.y + r.h > img.height)
|
|
74
|
+
throw new Error(`Rect exceeds ${img.width}x${img.height}`);
|
|
75
|
+
return { content: [{ type: "text", text: JSON.stringify({ rect: r, palette: palette(img, r) }, null, 2) }, { type: "image", data: b64(encode(crop(img, r))), mimeType: "image/png" }] };
|
|
76
|
+
}));
|
|
77
|
+
server.tool("measure_spacing", "Scan a horizontal or vertical line in a fresh screenshot and return runs of uniform color with pixel lengths. Use it to measure padding, gaps, row heights and element widths; divide pixels by pointScale for points.", {
|
|
78
|
+
device,
|
|
79
|
+
axis: z.enum(["x", "y"]).describe("x = scan left→right along row `at`; y = scan top→bottom along column `at`"),
|
|
80
|
+
at: z.number().int().describe("The fixed coordinate: the row (axis x) or column (axis y)"),
|
|
81
|
+
from: z.number().int(), to: z.number().int(),
|
|
82
|
+
tolerance: z.number().min(0).default(12).describe("RGB distance that counts as a new run"),
|
|
83
|
+
pointScale: z.number().positive().optional().describe("If set, also report lengths in points"),
|
|
84
|
+
}, ({ device, axis, at, from, to, tolerance, pointScale }) => wrap(async () => {
|
|
85
|
+
const img = decode(await screenshotPng(device));
|
|
86
|
+
const runs = scanLine(img, axis, at, from, to, tolerance).map((r) => ({ ...r, lengthPx: r.end - r.start + 1, ...(pointScale ? { lengthPt: +((r.end - r.start + 1) / pointScale).toFixed(2) } : {}) }));
|
|
87
|
+
return text({ image: `${img.width}x${img.height}`, runs });
|
|
88
|
+
}));
|
|
89
|
+
server.tool("contrast_check", "WCAG contrast ratio between two colors, given as hex or as screenshot points. Reports AA/AAA for normal and large text.", {
|
|
90
|
+
device: device.optional().describe("Required when using points"),
|
|
91
|
+
fg: z.union([z.string(), z.object({ x: z.number().int(), y: z.number().int() })]),
|
|
92
|
+
bg: z.union([z.string(), z.object({ x: z.number().int(), y: z.number().int() })]),
|
|
93
|
+
}, ({ device, fg, bg }) => wrap(async () => {
|
|
94
|
+
let img;
|
|
95
|
+
const resolve = async (v) => {
|
|
96
|
+
if (typeof v === "string")
|
|
97
|
+
return parseHex(v);
|
|
98
|
+
if (!device)
|
|
99
|
+
throw new Error("device is required when fg/bg are points");
|
|
100
|
+
img ??= decode(await screenshotPng(device));
|
|
101
|
+
return pixel(img, v.x, v.y);
|
|
102
|
+
};
|
|
103
|
+
const [f, b] = [await resolve(fg), await resolve(bg)];
|
|
104
|
+
const ratio = contrast(f, b);
|
|
105
|
+
return text({ fg: hex(f), bg: hex(b), ratio: +ratio.toFixed(2), AA_normal: ratio >= 4.5, AA_large: ratio >= 3, AAA_normal: ratio >= 7, AAA_large: ratio >= 4.5 });
|
|
106
|
+
}));
|
|
107
|
+
// ---- on the Mac: finding images --------------------------------------------------------------------------------
|
|
108
|
+
server.tool("clipboard_image", "Save the image currently on the macOS clipboard (a screenshot copied with Cmd+Ctrl+Shift+4, or an image copied from Preview/Safari) to a PNG file and return its path, so it can be given to analyze_reference or compare. Use this when the user says 'copy the UI in my clipboard'. If the user copied a file in Finder it returns that file's real path. For an image pasted into the prompt, try latest_pasted_image first.", {}, () => wrap(async () => {
|
|
109
|
+
// a file copied in Finder puts the file's icon on the clipboard as image data; use the real file instead
|
|
110
|
+
try {
|
|
111
|
+
const { stdout } = await run("osascript", ["-e", "POSIX path of (the clipboard as «class furl»)"]);
|
|
112
|
+
const path = stdout.trim();
|
|
113
|
+
if (/\.(png|jpe?g|heic|webp|tiff?)$/i.test(path) && (await stat(path).catch(() => null))) {
|
|
114
|
+
const png = path.toLowerCase().endsWith(".png") ? path : join(tmpdir(), "kelra-clipboard", `finder-${Date.now()}.png`);
|
|
115
|
+
if (png !== path) {
|
|
116
|
+
await mkdir(join(tmpdir(), "kelra-clipboard"), { recursive: true });
|
|
117
|
+
await run("sips", ["-s", "format", "png", path, "--out", png]);
|
|
118
|
+
}
|
|
119
|
+
const img = decode(await readFile(png));
|
|
120
|
+
return text({ file: png, source: "file copied in Finder", original: path, sizePx: [img.width, img.height] });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch { /* no file on the clipboard: fall through to image data */ }
|
|
124
|
+
const dir = join(tmpdir(), "kelra-clipboard");
|
|
125
|
+
await mkdir(dir, { recursive: true });
|
|
126
|
+
const file = join(dir, `clipboard-${Date.now()}.png`);
|
|
127
|
+
const write = (cls, target) => run("osascript", [
|
|
128
|
+
"-e", `set d to the clipboard as «class ${cls}»`,
|
|
129
|
+
"-e", `set f to open for access POSIX file "${target}" with write permission`,
|
|
130
|
+
"-e", "set eof of f to 0", "-e", "write d to f", "-e", "close access f",
|
|
131
|
+
]);
|
|
132
|
+
try {
|
|
133
|
+
await write("PNGf", file);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
const tiff = file.replace(/\.png$/, ".tiff");
|
|
137
|
+
try {
|
|
138
|
+
await write("TIFF", tiff);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error("The clipboard has no image. Copy a screenshot first (Cmd+Ctrl+Shift+4), or give a file path instead.");
|
|
142
|
+
}
|
|
143
|
+
await run("sips", ["-s", "format", "png", tiff, "--out", file]);
|
|
144
|
+
}
|
|
145
|
+
const img = decode(await readFile(file));
|
|
146
|
+
return text({ file, sizePx: [img.width, img.height], bytes: (await stat(file)).size });
|
|
147
|
+
}));
|
|
148
|
+
server.tool("latest_pasted_image", "Find the image the user most recently pasted into Claude Code (it is saved as a file even though the chat shows it inline) and return its path. Try this first when the user pasted a screenshot into the prompt; fall back to clipboard_image if nothing recent is found.", { maxAgeMinutes: z.number().positive().default(30) }, ({ maxAgeMinutes }) => wrap(async () => {
|
|
149
|
+
const uid = process.getuid?.() ?? 501;
|
|
150
|
+
const roots = [`/private/tmp/claude-${uid}`, `/tmp/claude-${uid}`];
|
|
151
|
+
const found = [];
|
|
152
|
+
for (const root of roots) {
|
|
153
|
+
const projects = await readdir(root).catch(() => []);
|
|
154
|
+
for (const proj of projects) {
|
|
155
|
+
const sessions = await readdir(join(root, proj)).catch(() => []);
|
|
156
|
+
for (const sess of sessions) {
|
|
157
|
+
const dir = join(root, proj, sess, "images");
|
|
158
|
+
for (const f of await readdir(dir).catch(() => []))
|
|
159
|
+
if (/\.(png|jpe?g|webp)$/i.test(f))
|
|
160
|
+
found.push({ file: join(dir, f), mtime: (await stat(join(dir, f))).mtimeMs });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const seen = new Set(); // /tmp is a link to /private/tmp, so the same file can be found twice
|
|
165
|
+
const unique = found.filter((f) => { const key = f.file.replace(/^\/private/, ""); if (seen.has(key))
|
|
166
|
+
return false; seen.add(key); return true; });
|
|
167
|
+
unique.sort((a, b) => b.mtime - a.mtime);
|
|
168
|
+
const newest = unique[0];
|
|
169
|
+
if (!newest || Date.now() - newest.mtime > maxAgeMinutes * 60_000)
|
|
170
|
+
throw new Error(`No image pasted in the last ${maxAgeMinutes} minutes. Use clipboard_image or ask for a file path.`);
|
|
171
|
+
let file = newest.file;
|
|
172
|
+
if (!/\.png$/i.test(file)) {
|
|
173
|
+
const out = join(tmpdir(), "kelra-clipboard", `pasted-${Date.now()}.png`);
|
|
174
|
+
await mkdir(join(tmpdir(), "kelra-clipboard"), { recursive: true });
|
|
175
|
+
await run("sips", ["-s", "format", "png", file, "--out", out]);
|
|
176
|
+
file = out;
|
|
177
|
+
}
|
|
178
|
+
const img = decode(await readFile(file));
|
|
179
|
+
return text({ file, pastedAt: new Date(newest.mtime).toISOString(), sizePx: [img.width, img.height], otherRecentImages: unique.slice(1, 4).map((f) => f.file) });
|
|
180
|
+
}));
|
|
181
|
+
// ---- analysis: read locally, measure text on the Mac, and ask the kelra service -------------------------------------
|
|
182
|
+
const trimmed = (png, top, bottom) => {
|
|
183
|
+
if (!top && !bottom)
|
|
184
|
+
return png;
|
|
185
|
+
const img = decode(png);
|
|
186
|
+
const h = img.height - top - bottom;
|
|
187
|
+
if (h < 100)
|
|
188
|
+
throw new Error("Trim leaves less than 100px of the image");
|
|
189
|
+
return encode(crop(img, { x: 0, y: top, w: img.width, h }));
|
|
190
|
+
};
|
|
191
|
+
server.tool("analyze_reference", "Turn a reference UI screenshot (local PNG path) into a measured spec: background color, a nested tree of surfaces (cards, bars) and content (text/icons) with positions, sizes, fills, ink colors and corner radii in points. Use it as the build spec when asked to copy a UI. `textLines` has the exact words with position, font size and weight (Apple Vision OCR); the element tree is boxes without words. Sizes assume the iOS system font and are good to about 5%.", {
|
|
192
|
+
path: z.string().describe("Absolute path to the reference PNG"),
|
|
193
|
+
pointScale: z.number().positive().default(3).describe("Pixels per point of the reference (3 for most modern iPhones)"),
|
|
194
|
+
tolerance: z.number().min(1).default(16).describe("RGB distance that separates content from background (16 sees a white card on a near-white page; raise it for noisy or compressed screenshots)"),
|
|
195
|
+
}, ({ path, pointScale, tolerance }) => wrap(async () => {
|
|
196
|
+
const png = await readPng(path, "reference image");
|
|
197
|
+
const ocr = await readRawBuffer(png, pointScale);
|
|
198
|
+
return text(await callService("/v1/analyze-reference", { image: b64(png), pointScale, tolerance, ocr }));
|
|
199
|
+
}));
|
|
200
|
+
server.tool("read_text", "Read the text in a screenshot with Apple Vision (OCR): the exact words, where each line sits (points), its color, and the system font size and weight that fit it, plus a ready-to-paste SwiftUI font modifier. Sizes assume the iOS system font and are good to about 5%; weight is a guess (adjacent weights are often confused, see weightConfidence); custom fonts only match by width. Lines flagged likelyIcon are glyphs read as letters. Use this instead of reading words by eye.", {
|
|
201
|
+
path: z.string().describe("Absolute path to the PNG"),
|
|
202
|
+
pointScale: z.number().positive().default(3).describe("Pixels per point of the screenshot"),
|
|
203
|
+
trimBottomPx: z.number().int().min(0).default(0),
|
|
204
|
+
minConfidence: z.number().min(0).max(1).default(0.4),
|
|
205
|
+
includeIcons: z.boolean().default(false),
|
|
206
|
+
}, ({ path, pointScale, trimBottomPx, minConfidence, includeIcons }) => wrap(async () => {
|
|
207
|
+
const png = trimmed(await readPng(path), 0, trimBottomPx);
|
|
208
|
+
const ocr = await readRawBuffer(png, pointScale);
|
|
209
|
+
if (!ocr)
|
|
210
|
+
throw new Error("Reading text needs a Mac with Xcode installed (Apple Vision).");
|
|
211
|
+
return text(await callService("/v1/read-text", { image: b64(png), pointScale, minConfidence, includeIcons, ocr }));
|
|
212
|
+
}));
|
|
213
|
+
server.tool("compare", "Compare a reference screenshot against the current render and return `status` (done or not, and the next step: follow it), `critical` (e.g. text invisible in the render: fix these first), `textAdjustments` (per text line, matched by its words: exact font-size multiplier, move, weight), `adjustments` (move/resize/recolor for elements that matched, largest first), `missingInRender` and `extraInRender` (structure differences), a similarity score, the worst-differing screen areas, and a red diff overlay. Render comes from a live device screenshot, or renderPath for a saved PNG. The reference is scaled to the render's size; the aspect ratios should match.", {
|
|
214
|
+
referencePath: z.string().describe("Absolute path to the reference PNG"),
|
|
215
|
+
device: device.optional().describe("Device to capture as the render"),
|
|
216
|
+
renderPath: z.string().optional().describe("Saved PNG to use instead of a device capture"),
|
|
217
|
+
pointScale: z.number().positive().default(3).describe("Pixels per point of the render"),
|
|
218
|
+
tolerance: z.number().min(1).default(16),
|
|
219
|
+
referenceTrimTopPx: z.number().int().min(0).default(0).describe("Pixels to cut from the top of the reference first, e.g. a status bar or a watermark header"),
|
|
220
|
+
referenceTrimBottomPx: z.number().int().min(0).default(0).describe("Pixels to cut from the bottom of the reference first, e.g. the Mobbin 'curated by' footer strip"),
|
|
221
|
+
ignoreTopPt: z.number().min(0).default(0).describe("Ignore the top N points of the RENDER (e.g. the simulator status bar)"),
|
|
222
|
+
ignoreBottomPt: z.number().min(0).default(0).describe("Ignore the bottom N points of the RENDER (e.g. home indicator area)"),
|
|
223
|
+
ignoreRegions: z.array(z.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() })).default([]).describe("Rectangles in render points to leave out of the comparison (live content, glass effects, product photos)"),
|
|
224
|
+
inspectPort: z.number().int().optional().describe("Port of KelraInspector in the running app. With a device, port 8765 is tried automatically. Adds the app's current exact font size and the size to set to each text adjustment."),
|
|
225
|
+
align: z.enum(["auto", "stretch"]).default("auto").describe("auto: fit the reference to the render by scale and offset (handles crops, footers, other device sizes); stretch: force it to the render's exact size"),
|
|
226
|
+
}, ({ referencePath, device, renderPath, pointScale, tolerance, align, inspectPort, referenceTrimTopPx, referenceTrimBottomPx, ignoreTopPt, ignoreBottomPt, ignoreRegions }) => wrap(async () => {
|
|
227
|
+
if (!device && !renderPath)
|
|
228
|
+
throw new Error("Provide device or renderPath");
|
|
229
|
+
const renderPng = renderPath ? await readPng(renderPath, "render image") : await settledScreenshotPng(device);
|
|
230
|
+
const refPng = trimmed(await readPng(referencePath, "reference image"), referenceTrimTopPx, referenceTrimBottomPx);
|
|
231
|
+
const render = decode(renderPng), ref = decode(refPng);
|
|
232
|
+
// the reference's text is read in its own pixels, assuming it is scaled to the render's width; the service corrects for the real alignment
|
|
233
|
+
const refPpp = (ref.width * pointScale) / render.width;
|
|
234
|
+
const [refOcr, renderOcr, live] = await Promise.all([
|
|
235
|
+
readRawBuffer(refPng, refPpp), readRawBuffer(renderPng, pointScale),
|
|
236
|
+
inspectPort || device ? fetchTree(inspectPort ?? 8765).catch(() => null) : Promise.resolve(null),
|
|
237
|
+
]);
|
|
238
|
+
const r = await callService("/v1/compare", {
|
|
239
|
+
reference: b64(refPng), render: b64(renderPng), pointScale, tolerance, ignoreTopPt, ignoreBottomPt, ignoreRegions, align,
|
|
240
|
+
refOcr: refOcr && renderOcr ? refOcr : null, refOcrPixelsPerPoint: refPpp, renderOcr: refOcr && renderOcr ? renderOcr : null, live,
|
|
241
|
+
});
|
|
242
|
+
const shape = align === "stretch" ? null : shapeWarning(ref, render);
|
|
243
|
+
if (shape)
|
|
244
|
+
r.report.warnings = [shape, ...(Array.isArray(r.report.warnings) ? r.report.warnings : [])];
|
|
245
|
+
return { content: [{ type: "text", text: JSON.stringify(r.report, null, 2) }, { type: "image", data: r.overlay, mimeType: "image/png" }] };
|
|
246
|
+
}));
|
|
247
|
+
server.tool("extract_assets", "Crop image regions out of a reference screenshot into PNG files, so a build can use real pictures where the reference has photos. Regions default to the detected photo regions (see analyze_reference); pass `regions` (in points) to choose exact rectangles, e.g. a whole card's photo area. For local prototyping: the pictures belong to whoever made the reference, so do not ship them.", {
|
|
248
|
+
path: z.string().describe("Absolute path to the reference PNG"),
|
|
249
|
+
outDir: z.string().describe("Directory to write the PNG files into (created if missing)"),
|
|
250
|
+
pointScale: z.number().positive().default(3),
|
|
251
|
+
trimBottomPx: z.number().int().min(0).default(0).describe("Pixels to cut from the bottom first (e.g. a Mobbin footer); coordinates then refer to the trimmed image"),
|
|
252
|
+
regions: z.array(z.object({ name: z.string(), x: z.number(), y: z.number(), w: z.number(), h: z.number() })).optional().describe("Rectangles in points; omit to use detected photo regions"),
|
|
253
|
+
}, ({ path, outDir, pointScale, trimBottomPx, regions }) => wrap(async () => {
|
|
254
|
+
const png = trimmed(await readPng(path), 0, trimBottomPx);
|
|
255
|
+
const img = decode(png);
|
|
256
|
+
const rects = regions
|
|
257
|
+
? regions.map((r) => ({ name: r.name, x: Math.round(r.x * pointScale), y: Math.round(r.y * pointScale), w: Math.round(r.w * pointScale), h: Math.round(r.h * pointScale) }))
|
|
258
|
+
: (await callService("/v1/image-regions", { image: b64(png) })).regions.map((r, i) => ({ name: `photo-${i + 1}`, ...r }));
|
|
259
|
+
await mkdir(outDir, { recursive: true });
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const r of rects) {
|
|
262
|
+
const x = Math.max(0, r.x), y = Math.max(0, r.y);
|
|
263
|
+
const w = Math.min(img.width - x, r.w), h = Math.min(img.height - y, r.h);
|
|
264
|
+
if (w < 2 || h < 2) {
|
|
265
|
+
out.push({ name: r.name, error: "outside the image" });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const file = join(outDir, `${r.name}.png`);
|
|
269
|
+
await writeFile(file, encode(crop(img, { x, y, w, h })));
|
|
270
|
+
out.push({ name: r.name, file, sizePx: [w, h], rectPt: { x: +(x / pointScale).toFixed(1), y: +(y / pointScale).toFixed(1), w: +(w / pointScale).toFixed(1), h: +(h / pointScale).toFixed(1) } });
|
|
271
|
+
}
|
|
272
|
+
return text(out);
|
|
273
|
+
}));
|
|
274
|
+
server.tool("inspect_app", "Read the running app's real layout from the KelraInspector Swift package (debug builds): every text, image and shape with its exact frame in points, shape fills and corner radii, UIKit label text/font/color, and for SwiftUI text the font size derived from the layer height (1.2 x size). Pass `device` (or `screenshotPath`) to also read the words, weight and color of SwiftUI text from a screenshot, and to get warnings for text layers that draw nothing readable (usually text colored like its background). Also returns layout facts: screen margins, alignment edges, vertical gaps between texts, and the padding inside each card. Needs the app running on a simulator with KelraInspector.start() called (see its README).", {
|
|
275
|
+
port: z.number().int().default(8765).describe("Port KelraInspector listens on"),
|
|
276
|
+
device: device.optional().describe("If given, take a screenshot and read SwiftUI text words/weight/color from it"),
|
|
277
|
+
screenshotPath: z.string().optional().describe("Alternative to device: a saved screenshot of the same screen"),
|
|
278
|
+
includeFacts: z.boolean().default(true),
|
|
279
|
+
}, ({ port, device, screenshotPath, includeFacts }) => wrap(async () => {
|
|
280
|
+
const tree = await fetchTree(port).catch((e) => {
|
|
281
|
+
throw new Error(`Could not reach KelraInspector on port ${port} (${e.message}). Add the KelraInspector package to the app, call KelraInspector.start() in a DEBUG build, and run it on a simulator.`);
|
|
282
|
+
});
|
|
283
|
+
let screenshot = null, ocr = null;
|
|
284
|
+
if (device || screenshotPath) {
|
|
285
|
+
screenshot = device ? await settledScreenshotPng(device) : await readPng(screenshotPath, "screenshot");
|
|
286
|
+
ocr = await readRawBuffer(screenshot, decode(screenshot).width / tree.screen.w);
|
|
287
|
+
}
|
|
288
|
+
return text(await callService("/v1/inspect", { tree, screenshot: screenshot ? b64(screenshot) : null, ocr, includeFacts }));
|
|
289
|
+
}));
|
|
290
|
+
await server.connect(new StdioServerTransport());
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const licensePath: () => string;
|
|
2
|
+
export interface Stored {
|
|
3
|
+
refreshToken?: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
expiresAt?: number;
|
|
6
|
+
email?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function readStored(): Stored;
|
|
9
|
+
/** Save with owner-only permissions (folder 0700, file 0600). Used by the login command. */
|
|
10
|
+
export declare function writeStored(data: Stored): string;
|
|
11
|
+
export declare function clearStored(): void;
|
|
12
|
+
/** Kept for a license pasted in directly (KELRA_TOKEN-style). */
|
|
13
|
+
export declare const saveToken: (token: string) => string;
|
|
14
|
+
/** The license stored or provided directly, without any network. (`currentLicense` in login.ts also renews it.) */
|
|
15
|
+
export declare function loadToken(): string | null;
|
package/dist/license.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Where the customer's sign-in lives, and how it is written: readable only by its owner.
|
|
2
|
+
// The file holds the machine's refresh token (from `kelra login`) and the short-lived license last fetched with it.
|
|
3
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
export const licensePath = () => join(process.env.KELRA_HOME ?? join(homedir(), ".kelra"), "license.json");
|
|
7
|
+
export function readStored() {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(licensePath(), "utf8"));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Save with owner-only permissions (folder 0700, file 0600). Used by the login command. */
|
|
16
|
+
export function writeStored(data) {
|
|
17
|
+
const file = licensePath();
|
|
18
|
+
mkdirSync(join(file, ".."), { recursive: true, mode: 0o700 });
|
|
19
|
+
writeFileSync(file, JSON.stringify(data), { mode: 0o600 });
|
|
20
|
+
chmodSync(file, 0o600); // also tightens a file that already existed with looser permissions
|
|
21
|
+
return file;
|
|
22
|
+
}
|
|
23
|
+
export function clearStored() { rmSync(licensePath(), { force: true }); }
|
|
24
|
+
/** Kept for a license pasted in directly (KELRA_TOKEN-style). */
|
|
25
|
+
export const saveToken = (token) => writeStored({ token });
|
|
26
|
+
/** The license stored or provided directly, without any network. (`currentLicense` in login.ts also renews it.) */
|
|
27
|
+
export function loadToken() {
|
|
28
|
+
return process.env.KELRA_TOKEN || readStored().token || null;
|
|
29
|
+
}
|
package/dist/live.d.ts
ADDED
package/dist/live.js
ADDED
package/dist/login.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare const accountApi: () => string;
|
|
2
|
+
export declare class LoginError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export interface LoginDeps {
|
|
5
|
+
fetchFn?: typeof fetch;
|
|
6
|
+
open?: (url: string) => void;
|
|
7
|
+
sleep?: (ms: number) => Promise<void>;
|
|
8
|
+
log?: (line: string) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function login(deps?: LoginDeps): Promise<{
|
|
11
|
+
email?: string;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function logout(): void;
|
|
14
|
+
export declare function whoami(): {
|
|
15
|
+
signedIn: boolean;
|
|
16
|
+
email?: string;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The license to send with requests. KELRA_TOKEN wins (CI, tests). Otherwise use the saved one while it has more than
|
|
20
|
+
* five minutes left, or trade the machine's refresh token for a fresh one (about every six hours).
|
|
21
|
+
*/
|
|
22
|
+
export declare function currentLicense(fetchFn?: typeof fetch): Promise<string | null>;
|
package/dist/login.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// `kelra login`: sign this Mac in through the browser (like the GitHub or Vercel command line tools).
|
|
2
|
+
// 1. ask the account service for a code, 2. open the browser where the customer approves it, 3. wait, 4. save the result.
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { apiBase } from "./config.js";
|
|
5
|
+
import { clearStored, readStored, writeStored } from "./license.js";
|
|
6
|
+
export const accountApi = () => (process.env.KELRA_ACCOUNT_API ?? apiBase()).replace(/\/$/, "");
|
|
7
|
+
export class LoginError extends Error {
|
|
8
|
+
}
|
|
9
|
+
async function post(fetchFn, path, body) {
|
|
10
|
+
let res;
|
|
11
|
+
try {
|
|
12
|
+
res = await fetchFn(accountApi() + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000) });
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
throw new LoginError(`Could not reach the kelra service (${e.message}). Check your internet connection.`);
|
|
16
|
+
}
|
|
17
|
+
return { status: res.status, json: (await res.json().catch(() => ({}))) };
|
|
18
|
+
}
|
|
19
|
+
export async function login(deps = {}) {
|
|
20
|
+
const fetchFn = deps.fetchFn ?? fetch, log = deps.log ?? console.log;
|
|
21
|
+
const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
22
|
+
const start = await post(fetchFn, "/v1/device/start", { label: hostname().replace(/\.local$/, "") });
|
|
23
|
+
if (start.status !== 200)
|
|
24
|
+
throw new LoginError(start.json.error ?? `Could not start sign-in (${start.status}).`);
|
|
25
|
+
const { deviceCode, userCode, verificationUrl, expiresIn } = start.json;
|
|
26
|
+
let interval = Math.max(1, start.json.interval);
|
|
27
|
+
const link = `${verificationUrl}?code=${encodeURIComponent(userCode)}`;
|
|
28
|
+
log(`\nTo sign in, open: ${link}\nYour code: ${userCode}\n`);
|
|
29
|
+
try {
|
|
30
|
+
deps.open?.(link);
|
|
31
|
+
}
|
|
32
|
+
catch { /* the link above is enough */ }
|
|
33
|
+
log("Waiting for you to approve in the browser…");
|
|
34
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
35
|
+
while (Date.now() < deadline) {
|
|
36
|
+
await sleep(interval * 1000);
|
|
37
|
+
const r = await post(fetchFn, "/v1/device/poll", { deviceCode });
|
|
38
|
+
if (r.status !== 200)
|
|
39
|
+
throw new LoginError(r.json.error ?? `Sign-in failed (${r.status}). Run \`npx kelra login\` again.`);
|
|
40
|
+
switch (r.json.status) {
|
|
41
|
+
case "pending": break;
|
|
42
|
+
case "slow_down":
|
|
43
|
+
interval = Math.max(interval, r.json.interval ?? interval) + 1;
|
|
44
|
+
break;
|
|
45
|
+
case "denied": throw new LoginError("Sign-in was canceled in the browser.");
|
|
46
|
+
case "device_limit": throw new LoginError(r.json.message ?? "This account is signed in on too many machines. Remove one in your dashboard.");
|
|
47
|
+
case "approved":
|
|
48
|
+
if (!r.json.refreshToken)
|
|
49
|
+
throw new LoginError("The service did not return a sign-in. Try again.");
|
|
50
|
+
writeStored({ refreshToken: r.json.refreshToken, email: r.json.email });
|
|
51
|
+
return { email: r.json.email };
|
|
52
|
+
default: throw new LoginError("Unexpected reply from the kelra service. Update with `npx kelra@latest`.");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
throw new LoginError("The code expired. Run `npx kelra login` again.");
|
|
56
|
+
}
|
|
57
|
+
export function logout() { clearStored(); }
|
|
58
|
+
export function whoami() {
|
|
59
|
+
const s = readStored();
|
|
60
|
+
return { signedIn: Boolean(s.refreshToken || s.token), email: s.email };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The license to send with requests. KELRA_TOKEN wins (CI, tests). Otherwise use the saved one while it has more than
|
|
64
|
+
* five minutes left, or trade the machine's refresh token for a fresh one (about every six hours).
|
|
65
|
+
*/
|
|
66
|
+
export async function currentLicense(fetchFn = fetch) {
|
|
67
|
+
if (process.env.KELRA_TOKEN)
|
|
68
|
+
return process.env.KELRA_TOKEN;
|
|
69
|
+
const s = readStored();
|
|
70
|
+
const now = Math.floor(Date.now() / 1000);
|
|
71
|
+
if (s.token && (s.expiresAt === undefined || s.expiresAt - now > 300))
|
|
72
|
+
return s.token;
|
|
73
|
+
if (!s.refreshToken)
|
|
74
|
+
return null;
|
|
75
|
+
const r = await post(fetchFn, "/v1/token", { refreshToken: s.refreshToken });
|
|
76
|
+
if (r.status === 401) {
|
|
77
|
+
writeStored({ email: s.email });
|
|
78
|
+
throw new LoginError("This machine was signed out (removed from your account). Run `npx kelra login`.");
|
|
79
|
+
}
|
|
80
|
+
if (r.status !== 200 || !r.json.token)
|
|
81
|
+
throw new LoginError(r.json.error ?? `Could not renew your license (${r.status}).`);
|
|
82
|
+
writeStored({ ...s, token: r.json.token, expiresAt: r.json.expiresAt });
|
|
83
|
+
return r.json.token;
|
|
84
|
+
}
|
package/dist/ocr.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { RawOcrLine } from "@kelra/protocol";
|
|
2
|
+
/** Compile the reader with the Xcode toolchain if it is not built yet. */
|
|
3
|
+
export declare function ensureOcr(): Promise<void>;
|
|
4
|
+
export declare function readRaw(pngPath: string, pixelsPerPoint: number): Promise<RawOcrLine[]>;
|
|
5
|
+
/** Raw readings of an in-memory PNG. Returns null where the reader cannot run (not a Mac, no Xcode), so the service still works without text. */
|
|
6
|
+
export declare function readRawBuffer(png: Buffer, pixelsPerPoint: number): Promise<RawOcrLine[] | null>;
|
package/dist/ocr.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Reads text lines from a screenshot with Apple Vision (macOS only) and returns raw measurements.
|
|
2
|
+
// The program makes no decisions about fonts: the kelra service does that.
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { developerDir } from "./developer.js";
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
13
|
+
const BIN = join(root, "bin", "kelra-ocr");
|
|
14
|
+
/** Compile the reader with the Xcode toolchain if it is not built yet. */
|
|
15
|
+
export async function ensureOcr() {
|
|
16
|
+
if (existsSync(BIN))
|
|
17
|
+
return;
|
|
18
|
+
await mkdir(join(root, "bin"), { recursive: true });
|
|
19
|
+
await run(join(developerDir(), "Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc"), [
|
|
20
|
+
"-O", join(root, "swift/ocr.swift"), "-o", BIN, "-sdk", join(developerDir(), "Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"),
|
|
21
|
+
], { timeout: 180_000 });
|
|
22
|
+
}
|
|
23
|
+
export async function readRaw(pngPath, pixelsPerPoint) {
|
|
24
|
+
await ensureOcr();
|
|
25
|
+
const { stdout } = await run(BIN, [pngPath, String(pixelsPerPoint)], { timeout: 60_000, maxBuffer: 16 * 1024 * 1024 });
|
|
26
|
+
return JSON.parse(stdout);
|
|
27
|
+
}
|
|
28
|
+
/** Raw readings of an in-memory PNG. Returns null where the reader cannot run (not a Mac, no Xcode), so the service still works without text. */
|
|
29
|
+
export async function readRawBuffer(png, pixelsPerPoint) {
|
|
30
|
+
try {
|
|
31
|
+
const dir = join(tmpdir(), "kelra-ocr");
|
|
32
|
+
await mkdir(dir, { recursive: true });
|
|
33
|
+
const file = join(dir, `img-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.png`);
|
|
34
|
+
await writeFile(file, png);
|
|
35
|
+
return await readRaw(file, pixelsPerPoint);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ApiError } from "@kelra/protocol";
|
|
2
|
+
export declare class ServiceError extends Error {
|
|
3
|
+
status: number;
|
|
4
|
+
code?: ApiError["code"] | undefined;
|
|
5
|
+
constructor(message: string, status: number, code?: ApiError["code"] | undefined);
|
|
6
|
+
}
|
|
7
|
+
export declare function callService<T>(path: string, body: unknown, attempt?: number): Promise<T>;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { apiBase } from "./config.js";
|
|
2
|
+
import { currentLicense } from "./login.js";
|
|
3
|
+
const BASE = apiBase;
|
|
4
|
+
export class ServiceError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
code;
|
|
7
|
+
constructor(message, status, code) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.status = status;
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export async function callService(path, body, attempt = 1) {
|
|
14
|
+
const token = await currentLicense();
|
|
15
|
+
if (!token)
|
|
16
|
+
throw new ServiceError("No kelra license found. Sign in at kelra.net (run `npx kelra login`), or set KELRA_TOKEN.", 401, "unauthorized");
|
|
17
|
+
let res;
|
|
18
|
+
try {
|
|
19
|
+
res = await fetch(BASE() + path, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(body), signal: AbortSignal.timeout(120_000) });
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
if (attempt < 2)
|
|
23
|
+
return callService(path, body, attempt + 1);
|
|
24
|
+
throw new ServiceError(`Could not reach the kelra service (${e.message}). Check your internet connection.`, 0);
|
|
25
|
+
}
|
|
26
|
+
if (res.status >= 502 && res.status <= 504 && attempt < 2)
|
|
27
|
+
return callService(path, body, attempt + 1);
|
|
28
|
+
const json = (await res.json().catch(() => ({})));
|
|
29
|
+
if (!res.ok)
|
|
30
|
+
throw new ServiceError(json.error ?? `kelra service error (${res.status})`, res.status, json.code);
|
|
31
|
+
return json;
|
|
32
|
+
}
|
package/dist/settle.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Fraction of pixels that differ noticeably between two same-size PNG screenshots (1 when the sizes differ). */
|
|
2
|
+
export declare function diffRatio(a: Buffer, b: Buffer, tolerance?: number): number;
|
|
3
|
+
export interface SettleOptions {
|
|
4
|
+
/** Most captures to take. */
|
|
5
|
+
maxTries?: number;
|
|
6
|
+
/** Pause between captures. */
|
|
7
|
+
gapMs?: number;
|
|
8
|
+
/** Two frames count as the same when at most this fraction of pixels differs. */
|
|
9
|
+
threshold?: number;
|
|
10
|
+
sleep?: (ms: number) => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
/** Captures until two frames in a row match, and returns the last one. Gives up after `maxTries` and returns the latest frame. */
|
|
13
|
+
export declare function captureSettled(capture: () => Promise<Buffer>, opts?: SettleOptions): Promise<{
|
|
14
|
+
png: Buffer;
|
|
15
|
+
captures: number;
|
|
16
|
+
settled: boolean;
|
|
17
|
+
}>;
|
package/dist/settle.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Waits for the screen to stop changing before it is measured, so a launch animation is never mistaken for the app.
|
|
2
|
+
import { decode } from "./image.js";
|
|
3
|
+
/** Fraction of pixels that differ noticeably between two same-size PNG screenshots (1 when the sizes differ). */
|
|
4
|
+
export function diffRatio(a, b, tolerance = 8) {
|
|
5
|
+
const x = decode(a), y = decode(b);
|
|
6
|
+
if (x.width !== y.width || x.height !== y.height)
|
|
7
|
+
return 1;
|
|
8
|
+
let changed = 0;
|
|
9
|
+
for (let i = 0; i < x.data.length; i += 4) {
|
|
10
|
+
if (Math.abs(x.data[i] - y.data[i]) > tolerance || Math.abs(x.data[i + 1] - y.data[i + 1]) > tolerance || Math.abs(x.data[i + 2] - y.data[i + 2]) > tolerance)
|
|
11
|
+
changed++;
|
|
12
|
+
}
|
|
13
|
+
return changed / (x.width * x.height);
|
|
14
|
+
}
|
|
15
|
+
/** Captures until two frames in a row match, and returns the last one. Gives up after `maxTries` and returns the latest frame. */
|
|
16
|
+
export async function captureSettled(capture, opts = {}) {
|
|
17
|
+
const { maxTries = 6, gapMs = 600, threshold = 0.002, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = opts;
|
|
18
|
+
let previous = await capture();
|
|
19
|
+
for (let n = 2; n <= maxTries; n++) {
|
|
20
|
+
await sleep(gapMs);
|
|
21
|
+
const next = await capture();
|
|
22
|
+
if (diffRatio(previous, next) <= threshold)
|
|
23
|
+
return { png: next, captures: n, settled: true };
|
|
24
|
+
previous = next;
|
|
25
|
+
}
|
|
26
|
+
return { png: previous, captures: maxTries, settled: false };
|
|
27
|
+
}
|