getobsrv 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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/bin/obsrv-mcp.js +19 -0
- package/bin/obsrv.js +73 -0
- package/out/cli/args.js +204 -0
- package/out/main/cli.js +600 -0
- package/out/main/index.js +539 -0
- package/out/main/targetSource-w_vWw7zd.js +351 -0
- package/out/mcp/lib.js +175 -0
- package/out/mcp/server.js +301 -0
- package/out/preload/app.js +87 -0
- package/out/preload/sync.js +41 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-CTOM6hUh.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-600-normal-fLZuRloM.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-9HEixskS.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-cyrillic-ext-600-normal-V-xxqcpd.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-D38SheWl.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-latin-ext-600-normal-DmB0ttJJ.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-D2EvbN8M.woff2 +0 -0
- package/out/renderer/assets/ibm-plex-mono-vietnamese-600-normal-iLQfcSjf.woff +0 -0
- package/out/renderer/assets/index-BAEO9_6W.js +14460 -0
- package/out/renderer/assets/index-VleEBVgL.css +446 -0
- package/out/renderer/index.html +13 -0
- package/out/shared/calibration.js +41 -0
- package/out/shared/presets.js +47 -0
- package/out/shared/types.js +2 -0
- package/package.json +62 -0
- package/skills/obsrv-screens/SKILL.md +80 -0
package/out/main/cli.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const electron = require("electron");
|
|
3
|
+
const node_fs = require("node:fs");
|
|
4
|
+
const node_os = require("node:os");
|
|
5
|
+
const node_path = require("node:path");
|
|
6
|
+
const targetSource = require("./targetSource-w_vWw7zd.js");
|
|
7
|
+
function boxDownsample(src, factor) {
|
|
8
|
+
if (!Number.isInteger(factor) || factor < 1) throw new RangeError("factor must be an integer >= 1");
|
|
9
|
+
const width = Math.floor(src.width / factor);
|
|
10
|
+
const height = Math.floor(src.height / factor);
|
|
11
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
12
|
+
const n = factor * factor;
|
|
13
|
+
for (let y = 0; y < height; y++) {
|
|
14
|
+
for (let x = 0; x < width; x++) {
|
|
15
|
+
let r = 0, g = 0, b = 0, a = 0;
|
|
16
|
+
for (let dy = 0; dy < factor; dy++) {
|
|
17
|
+
let i = ((y * factor + dy) * src.width + x * factor) * 4;
|
|
18
|
+
for (let dx = 0; dx < factor; dx++, i += 4) {
|
|
19
|
+
r += src.data[i];
|
|
20
|
+
g += src.data[i + 1];
|
|
21
|
+
b += src.data[i + 2];
|
|
22
|
+
a += src.data[i + 3];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const o = (y * width + x) * 4;
|
|
26
|
+
data[o] = Math.round(r / n);
|
|
27
|
+
data[o + 1] = Math.round(g / n);
|
|
28
|
+
data[o + 2] = Math.round(b / n);
|
|
29
|
+
data[o + 3] = Math.round(a / n);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { width, height, data };
|
|
33
|
+
}
|
|
34
|
+
function rgbaToBgra(src) {
|
|
35
|
+
const out = new Uint8Array(src.data.length);
|
|
36
|
+
for (let i = 0; i < src.data.length; i += 4) {
|
|
37
|
+
out[i] = src.data[i + 2];
|
|
38
|
+
out[i + 1] = src.data[i + 1];
|
|
39
|
+
out[i + 2] = src.data[i];
|
|
40
|
+
out[i + 3] = 255;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
class ArgError extends Error {
|
|
45
|
+
}
|
|
46
|
+
const DEFAULT_PRESET = "1080p-24";
|
|
47
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
48
|
+
function usage() {
|
|
49
|
+
const presets = targetSource.SCREEN_PRESETS.map((p) => ` ${p.id.padEnd(14)} ${p.label}`).join("\n");
|
|
50
|
+
const profiles = targetSource.PANEL_PROFILES.map((p) => p.id).join(" | ");
|
|
51
|
+
return `obsrv — see your site the way 1x screens see it (headless CLI)
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
obsrv snap <url> [flags] Render <url> on a target screen; write a PNG, print JSON.
|
|
55
|
+
obsrv diff <url> [flags] Render <url> at 1x and against a 2x reference; print JSON metrics.
|
|
56
|
+
|
|
57
|
+
Shared flags:
|
|
58
|
+
--preset <id> Screen preset (default ${DEFAULT_PRESET}):
|
|
59
|
+
${presets}
|
|
60
|
+
--width <px> --height <px> [--dsf <factor>] [--diagonal <inches>]
|
|
61
|
+
Custom CSS viewport instead of --preset (dsf defaults to 1).
|
|
62
|
+
--profile <id> Panel profile: ${profiles} (default reference).
|
|
63
|
+
--wait <ms> Extra settle time after load (default 0).
|
|
64
|
+
--timeout <ms> Per-render budget for load + paint quiescence (default ${DEFAULT_TIMEOUT_MS}).
|
|
65
|
+
|
|
66
|
+
snap flags:
|
|
67
|
+
--out <file> Output PNG (default ./obsrv-<preset>.png). Under --matrix: an
|
|
68
|
+
output directory, or a pattern containing {preset}.
|
|
69
|
+
--full-page Capture the full page height (device pixels capped at 4096).
|
|
70
|
+
--matrix <id,id,…> Render each listed preset in one run.
|
|
71
|
+
|
|
72
|
+
diff flags:
|
|
73
|
+
--out-dir <dir> Also write target.png and reference.png.
|
|
74
|
+
--json JSON metrics to stdout (already the default; accepted for clarity).
|
|
75
|
+
|
|
76
|
+
Repeated flags: the last occurrence wins.
|
|
77
|
+
Machine output (JSON) goes to stdout; everything human goes to stderr.
|
|
78
|
+
Exit code 0 on success — diff findings are informational, never a failure.`;
|
|
79
|
+
}
|
|
80
|
+
const BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["full-page", "json"]);
|
|
81
|
+
const VALUE_FLAGS = /* @__PURE__ */ new Set(["preset", "profile", "out", "out-dir", "wait", "timeout", "matrix", "width", "height", "dsf", "diagonal"]);
|
|
82
|
+
const SNAP_ONLY = /* @__PURE__ */ new Set(["out", "full-page", "matrix"]);
|
|
83
|
+
const DIFF_ONLY = /* @__PURE__ */ new Set(["out-dir", "json"]);
|
|
84
|
+
function collect(command, argv) {
|
|
85
|
+
let url = null;
|
|
86
|
+
const flags = /* @__PURE__ */ new Map();
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const token = argv[i];
|
|
89
|
+
if (token.startsWith("--")) {
|
|
90
|
+
const name = token.slice(2);
|
|
91
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
92
|
+
flags.set(name, true);
|
|
93
|
+
} else if (VALUE_FLAGS.has(name)) {
|
|
94
|
+
const value = argv[++i];
|
|
95
|
+
if (value === void 0 || value.startsWith("--")) throw new ArgError(`--${name} requires a value`);
|
|
96
|
+
flags.set(name, value);
|
|
97
|
+
} else {
|
|
98
|
+
throw new ArgError(`unknown flag: --${name}
|
|
99
|
+
|
|
100
|
+
${usage()}`);
|
|
101
|
+
}
|
|
102
|
+
} else if (url === null) {
|
|
103
|
+
url = token;
|
|
104
|
+
} else {
|
|
105
|
+
throw new ArgError(`unexpected argument: ${token}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (url === null) throw new ArgError(`usage: obsrv ${command} <url> [flags] — run \`obsrv --help\` for the flag list`);
|
|
109
|
+
const wrongCommand = command === "snap" ? DIFF_ONLY : SNAP_ONLY;
|
|
110
|
+
for (const name of flags.keys()) {
|
|
111
|
+
if (wrongCommand.has(name)) {
|
|
112
|
+
throw new ArgError(`--${name} is a ${command === "snap" ? "diff" : "snap"} flag; \`obsrv ${command}\` does not take it`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { url, flags };
|
|
116
|
+
}
|
|
117
|
+
function integer(flags, name, fallback, min) {
|
|
118
|
+
const raw = flags.get(name);
|
|
119
|
+
if (raw === void 0) return fallback;
|
|
120
|
+
const n = Number(raw);
|
|
121
|
+
if (!Number.isFinite(n)) throw new ArgError(`--${name}: expected a number, got "${String(raw)}"`);
|
|
122
|
+
if (!Number.isInteger(n)) throw new ArgError(`--${name}: expected an integer, got "${String(raw)}"`);
|
|
123
|
+
if (n < min) throw new ArgError(`--${name}: must be >= ${min}`);
|
|
124
|
+
return n;
|
|
125
|
+
}
|
|
126
|
+
function float(flags, name, fallback, min) {
|
|
127
|
+
const raw = flags.get(name);
|
|
128
|
+
if (raw === void 0) return fallback;
|
|
129
|
+
const n = Number(raw);
|
|
130
|
+
if (!Number.isFinite(n) || n < min) throw new ArgError(`--${name}: expected a number >= ${min}, got "${String(raw)}"`);
|
|
131
|
+
return n;
|
|
132
|
+
}
|
|
133
|
+
function presetSpec(id) {
|
|
134
|
+
let preset;
|
|
135
|
+
try {
|
|
136
|
+
preset = targetSource.findPreset(id);
|
|
137
|
+
} catch {
|
|
138
|
+
throw new ArgError(`unknown preset: ${id} (valid: ${targetSource.SCREEN_PRESETS.map((p) => p.id).join(", ")})`);
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
presetId: preset.id,
|
|
142
|
+
cssWidth: preset.width,
|
|
143
|
+
cssHeight: preset.height,
|
|
144
|
+
deviceScaleFactor: preset.deviceScaleFactor,
|
|
145
|
+
diagonalInches: preset.diagonalInches
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function resolveSpecs(flags) {
|
|
149
|
+
const custom = ["width", "height", "dsf", "diagonal"].some((f) => flags.has(f));
|
|
150
|
+
if (custom && flags.has("preset")) throw new ArgError("--preset and --width/--height are mutually exclusive");
|
|
151
|
+
if (custom && flags.has("matrix")) throw new ArgError("--matrix lists presets; it cannot be combined with custom --width/--height dims");
|
|
152
|
+
if (flags.has("matrix") && flags.has("preset")) throw new ArgError("--matrix already lists presets; drop --preset");
|
|
153
|
+
if (custom) {
|
|
154
|
+
if (!flags.has("width") || !flags.has("height")) {
|
|
155
|
+
throw new ArgError("custom dims need both --width and --height (with optional --dsf and --diagonal)");
|
|
156
|
+
}
|
|
157
|
+
const cssWidth = integer(flags, "width", 0, 1);
|
|
158
|
+
const cssHeight = integer(flags, "height", 0, 1);
|
|
159
|
+
const deviceScaleFactor = float(flags, "dsf", 1, 1);
|
|
160
|
+
const max = targetSource.maxCssViewport(deviceScaleFactor);
|
|
161
|
+
if (cssWidth > max || cssHeight > max) {
|
|
162
|
+
throw new ArgError(`viewport exceeds the 4096-device-pixel budget: at dsf ${deviceScaleFactor} the CSS limit is ${max}`);
|
|
163
|
+
}
|
|
164
|
+
const diagonal = flags.has("diagonal") ? float(flags, "diagonal", 0, 0.1) : null;
|
|
165
|
+
return { specs: [{ presetId: "custom", cssWidth, cssHeight, deviceScaleFactor, diagonalInches: diagonal }], matrix: false };
|
|
166
|
+
}
|
|
167
|
+
const matrixRaw = flags.get("matrix");
|
|
168
|
+
if (typeof matrixRaw === "string") {
|
|
169
|
+
const ids = matrixRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
|
|
170
|
+
if (ids.length === 0) throw new ArgError("--matrix: expected a comma-separated list of preset ids");
|
|
171
|
+
return { specs: ids.map(presetSpec), matrix: true };
|
|
172
|
+
}
|
|
173
|
+
const id = typeof flags.get("preset") === "string" ? flags.get("preset") : DEFAULT_PRESET;
|
|
174
|
+
return { specs: [presetSpec(id)], matrix: false };
|
|
175
|
+
}
|
|
176
|
+
function resolveProfile(flags) {
|
|
177
|
+
const raw = flags.get("profile");
|
|
178
|
+
if (raw === void 0) return "reference";
|
|
179
|
+
try {
|
|
180
|
+
return targetSource.findProfile(raw).id;
|
|
181
|
+
} catch {
|
|
182
|
+
throw new ArgError(`unknown profile: ${String(raw)} (valid: ${targetSource.PANEL_PROFILES.map((p) => p.id).join(", ")})`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function parseArgs(argv) {
|
|
186
|
+
const [command, ...rest] = argv;
|
|
187
|
+
if (command === void 0 || command === "help" || command === "--help" || command === "-h") {
|
|
188
|
+
return { command: "help", text: usage() };
|
|
189
|
+
}
|
|
190
|
+
if (command !== "snap" && command !== "diff") {
|
|
191
|
+
throw new ArgError(`unknown command: ${command}
|
|
192
|
+
|
|
193
|
+
${usage()}`);
|
|
194
|
+
}
|
|
195
|
+
const { url, flags } = collect(command, rest);
|
|
196
|
+
const { specs, matrix } = resolveSpecs(flags);
|
|
197
|
+
const profileId = resolveProfile(flags);
|
|
198
|
+
const waitMs = integer(flags, "wait", 0, 0);
|
|
199
|
+
const timeoutMs = integer(flags, "timeout", DEFAULT_TIMEOUT_MS, 1);
|
|
200
|
+
if (command === "snap") {
|
|
201
|
+
const out = typeof flags.get("out") === "string" ? flags.get("out") : matrix ? "." : `obsrv-${specs[0].presetId}.png`;
|
|
202
|
+
return { command, url, specs, profileId, out, matrix, fullPage: flags.has("full-page"), waitMs, timeoutMs };
|
|
203
|
+
}
|
|
204
|
+
const spec = specs[0];
|
|
205
|
+
if (spec.deviceScaleFactor !== 1) {
|
|
206
|
+
throw new ArgError(
|
|
207
|
+
`diff compares the target against a 2x reference downsampled onto the target's 1x grid, so it only supports 1x targets in v1 — "${spec.presetId}" renders at ${spec.deviceScaleFactor}x. Use a 1x preset (e.g. laptop-768, 1080p-24) or plain \`obsrv snap\` for dense presets.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const referenceMax = targetSource.maxCssViewport(2);
|
|
211
|
+
if (spec.cssWidth > referenceMax || spec.cssHeight > referenceMax) {
|
|
212
|
+
throw new ArgError(
|
|
213
|
+
`diff renders a 2x reference, so the CSS viewport must fit ${referenceMax}px per axis (4096 device px at 2x) — "${spec.presetId}" is ${spec.cssWidth}×${spec.cssHeight}. Use \`obsrv snap\` for this preset instead.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const outDir = typeof flags.get("out-dir") === "string" ? flags.get("out-dir") : null;
|
|
217
|
+
return { command, url, spec, profileId, outDir, waitMs, timeoutMs };
|
|
218
|
+
}
|
|
219
|
+
const sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
220
|
+
const DEFAULT_SETTLE_MS = 400;
|
|
221
|
+
async function captureQuiescent(source, options = {}) {
|
|
222
|
+
const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS;
|
|
223
|
+
const timeoutMs = options.timeoutMs ?? 3e4;
|
|
224
|
+
let width = 0;
|
|
225
|
+
let height = 0;
|
|
226
|
+
let buffer = new Uint8Array(0);
|
|
227
|
+
let covered = false;
|
|
228
|
+
let mask = null;
|
|
229
|
+
let uncovered = 0;
|
|
230
|
+
let lastPaint = Date.now();
|
|
231
|
+
const onFrame = (m) => {
|
|
232
|
+
lastPaint = Date.now();
|
|
233
|
+
if (m.frameWidth !== width || m.frameHeight !== height) {
|
|
234
|
+
width = m.frameWidth;
|
|
235
|
+
height = m.frameHeight;
|
|
236
|
+
buffer = new Uint8Array(width * height * 4);
|
|
237
|
+
covered = false;
|
|
238
|
+
mask = new Uint8Array(width * height);
|
|
239
|
+
uncovered = width * height;
|
|
240
|
+
}
|
|
241
|
+
const { x, y, width: w, height: h, data } = m.frame;
|
|
242
|
+
if (x === 0 && y === 0 && w === width && h === height) {
|
|
243
|
+
buffer.set(data);
|
|
244
|
+
covered = true;
|
|
245
|
+
mask = null;
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
for (let row = 0; row < h; row++) {
|
|
249
|
+
const src = row * w * 4;
|
|
250
|
+
buffer.set(data.subarray(src, src + w * 4), ((y + row) * width + x) * 4);
|
|
251
|
+
}
|
|
252
|
+
if (!covered && mask) {
|
|
253
|
+
for (let row = 0; row < h; row++) {
|
|
254
|
+
let p = (y + row) * width + x;
|
|
255
|
+
for (let col = 0; col < w; col++, p++) {
|
|
256
|
+
if (mask[p] === 0) {
|
|
257
|
+
mask[p] = 1;
|
|
258
|
+
uncovered--;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (uncovered === 0) {
|
|
263
|
+
covered = true;
|
|
264
|
+
mask = null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
source.on("frame", onFrame);
|
|
269
|
+
try {
|
|
270
|
+
source.invalidate();
|
|
271
|
+
let settled = true;
|
|
272
|
+
const deadline = Date.now() + timeoutMs;
|
|
273
|
+
for (; ; ) {
|
|
274
|
+
const failed = options.failure?.();
|
|
275
|
+
if (failed) throw failed;
|
|
276
|
+
if (covered && Date.now() - lastPaint >= settleMs) break;
|
|
277
|
+
if (Date.now() >= deadline) {
|
|
278
|
+
if (!covered) throw new Error(`no full frame painted within ${timeoutMs} ms`);
|
|
279
|
+
options.onWarn?.(`page kept painting for ${timeoutMs} ms (animation?); capturing the current frame`);
|
|
280
|
+
settled = false;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
await sleep$1(Math.min(50, settleMs));
|
|
284
|
+
}
|
|
285
|
+
return { width, height, bgra: buffer.slice(), settled };
|
|
286
|
+
} finally {
|
|
287
|
+
source.off("frame", onFrame);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function bgraToRgba(bgra, width, height) {
|
|
291
|
+
const data = new Uint8ClampedArray(bgra.length);
|
|
292
|
+
for (let i = 0; i < bgra.length; i += 4) {
|
|
293
|
+
data[i] = bgra[i + 2];
|
|
294
|
+
data[i + 1] = bgra[i + 1];
|
|
295
|
+
data[i + 2] = bgra[i];
|
|
296
|
+
data[i + 3] = 255;
|
|
297
|
+
}
|
|
298
|
+
return { width, height, data };
|
|
299
|
+
}
|
|
300
|
+
const INK_LUMINANCE = 200;
|
|
301
|
+
const BAND_COUNT = 8;
|
|
302
|
+
const FINDING_THRESHOLD = 5e-3;
|
|
303
|
+
function luminance(r, g, b) {
|
|
304
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
305
|
+
}
|
|
306
|
+
const isInk = (d, i) => luminance(d[i], d[i + 1], d[i + 2]) < INK_LUMINANCE;
|
|
307
|
+
function inkCoverage(img) {
|
|
308
|
+
let ink = 0;
|
|
309
|
+
for (let i = 0; i < img.data.length; i += 4) {
|
|
310
|
+
if (isInk(img.data, i)) ink++;
|
|
311
|
+
}
|
|
312
|
+
return ink / (img.width * img.height);
|
|
313
|
+
}
|
|
314
|
+
function inkRows(img) {
|
|
315
|
+
let rows = 0;
|
|
316
|
+
for (let y = 0; y < img.height; y++) {
|
|
317
|
+
for (let x = 0; x < img.width; x++) {
|
|
318
|
+
if (isInk(img.data, (y * img.width + x) * 4)) {
|
|
319
|
+
rows++;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return rows;
|
|
325
|
+
}
|
|
326
|
+
function bandInk(img, y0, y1) {
|
|
327
|
+
let ink = 0;
|
|
328
|
+
for (let y = y0; y < y1; y++) {
|
|
329
|
+
for (let x = 0; x < img.width; x++) {
|
|
330
|
+
if (isInk(img.data, (y * img.width + x) * 4)) ink++;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const pixels = (y1 - y0) * img.width;
|
|
334
|
+
return pixels === 0 ? 0 : ink / pixels;
|
|
335
|
+
}
|
|
336
|
+
const pct = (v) => `${(v * 100).toFixed(2)}%`;
|
|
337
|
+
function diffMetrics(target, reference, referenceDeviceRows) {
|
|
338
|
+
if (target.width !== reference.width || target.height !== reference.height) {
|
|
339
|
+
throw new RangeError(
|
|
340
|
+
`diffMetrics: mismatched dimensions (target ${target.width}x${target.height}, reference ${reference.width}x${reference.height})`
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
const targetCoverage = inkCoverage(target);
|
|
344
|
+
const referenceCoverage = inkCoverage(reference);
|
|
345
|
+
const targetRows = inkRows(target);
|
|
346
|
+
const bands = [];
|
|
347
|
+
const findings = [];
|
|
348
|
+
for (let i = 0; i < BAND_COUNT; i++) {
|
|
349
|
+
const y0 = Math.floor(target.height * i / BAND_COUNT);
|
|
350
|
+
const y1 = Math.floor(target.height * (i + 1) / BAND_COUNT);
|
|
351
|
+
const targetInk = bandInk(target, y0, y1);
|
|
352
|
+
const referenceInk = bandInk(reference, y0, y1);
|
|
353
|
+
const delta = targetInk - referenceInk;
|
|
354
|
+
bands.push({ y0, y1, targetInk, referenceInk, delta });
|
|
355
|
+
if (Math.abs(delta) > FINDING_THRESHOLD) {
|
|
356
|
+
const direction = delta < 0 ? "target loses ink vs the 2x reference (thin strokes weakening or dropping out)" : "target gains ink vs the 2x reference (strokes thickening or darkening)";
|
|
357
|
+
findings.push(
|
|
358
|
+
`band ${i} (y ${y0}–${y1}): ink ${pct(targetInk)} vs ${pct(referenceInk)} reference (${delta >= 0 ? "+" : ""}${(delta * 100).toFixed(2)}pp) — ${direction}`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
inkCoverage: { target: targetCoverage, reference: referenceCoverage, delta: targetCoverage - referenceCoverage },
|
|
364
|
+
rows: {
|
|
365
|
+
target: targetRows,
|
|
366
|
+
reference: referenceDeviceRows,
|
|
367
|
+
ratio: referenceDeviceRows > 0 ? targetRows / referenceDeviceRows : null
|
|
368
|
+
},
|
|
369
|
+
bands,
|
|
370
|
+
findings
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
function profileToParams(p, hostNits) {
|
|
374
|
+
if (!(hostNits > 0)) throw new RangeError("hostNits must be > 0");
|
|
375
|
+
return {
|
|
376
|
+
brightness: p.nits === null ? 1 : p.nits / hostNits,
|
|
377
|
+
blackFloor: p.contrastRatio === null ? 0 : 1 / p.contrastRatio,
|
|
378
|
+
gamut: p.gamutCoverage,
|
|
379
|
+
levels: 2 ** p.bits - 1,
|
|
380
|
+
dither: p.frc
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
|
|
384
|
+
function bayer(x, y) {
|
|
385
|
+
return BAYER4[(y & 3) * 4 + (x & 3)] / 16;
|
|
386
|
+
}
|
|
387
|
+
const GAMMA = 2.2;
|
|
388
|
+
const toLinear = (v) => Math.pow(v, GAMMA);
|
|
389
|
+
const toEncoded = (v) => Math.pow(v, 1 / GAMMA);
|
|
390
|
+
const clamp01 = (v) => Math.min(1, Math.max(0, v));
|
|
391
|
+
function simulatePixel(rgb, params, x = 0, y = 0) {
|
|
392
|
+
let c = rgb.map((v) => toLinear(v / 255));
|
|
393
|
+
c = c.map((v) => params.brightness * (params.blackFloor + (1 - params.blackFloor) * v));
|
|
394
|
+
const l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
395
|
+
c = c.map((v) => l + (v - l) * params.gamut);
|
|
396
|
+
c = c.map((v) => toEncoded(clamp01(v)));
|
|
397
|
+
const d = params.dither ? bayer(x, y) - 0.5 : 0;
|
|
398
|
+
c = c.map((v) => Math.floor(v * params.levels + d + 0.5) / params.levels);
|
|
399
|
+
return c.map((v) => Math.round(clamp01(v) * 255));
|
|
400
|
+
}
|
|
401
|
+
function applyPanelProfile(img, profile, hostNits = targetSource.DEFAULT_SETTINGS.hostNits) {
|
|
402
|
+
if (profile.id === "reference") return img;
|
|
403
|
+
const params = profileToParams(profile, hostNits);
|
|
404
|
+
const out = new Uint8ClampedArray(img.data.length);
|
|
405
|
+
for (let y = 0; y < img.height; y++) {
|
|
406
|
+
for (let x = 0; x < img.width; x++) {
|
|
407
|
+
const i = (y * img.width + x) * 4;
|
|
408
|
+
const [r, g, b] = simulatePixel([img.data[i], img.data[i + 1], img.data[i + 2]], params, x, y);
|
|
409
|
+
out[i] = r;
|
|
410
|
+
out[i + 1] = g;
|
|
411
|
+
out[i + 2] = b;
|
|
412
|
+
out[i + 3] = img.data[i + 3];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { width: img.width, height: img.height, data: out };
|
|
416
|
+
}
|
|
417
|
+
function cliArgv() {
|
|
418
|
+
const sep = process.argv.indexOf("--");
|
|
419
|
+
return sep >= 0 ? process.argv.slice(sep + 1) : process.argv.slice(2);
|
|
420
|
+
}
|
|
421
|
+
const human = (message) => {
|
|
422
|
+
process.stderr.write(`${message}
|
|
423
|
+
`);
|
|
424
|
+
};
|
|
425
|
+
const machine = (json) => new Promise((done) => {
|
|
426
|
+
process.stdout.write(`${JSON.stringify(json, null, 2)}
|
|
427
|
+
`, () => done());
|
|
428
|
+
});
|
|
429
|
+
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
430
|
+
function encodePng(img) {
|
|
431
|
+
const bgra = rgbaToBgra(img);
|
|
432
|
+
const image = electron.nativeImage.createFromBitmap(Buffer.from(bgra.buffer, bgra.byteOffset, bgra.byteLength), {
|
|
433
|
+
width: img.width,
|
|
434
|
+
height: img.height
|
|
435
|
+
});
|
|
436
|
+
return image.toPNG();
|
|
437
|
+
}
|
|
438
|
+
async function render(url, spec, options) {
|
|
439
|
+
const target = new targetSource.TargetSource(30, { mobileEmulation: options.mobileEmulation ?? true });
|
|
440
|
+
try {
|
|
441
|
+
const failure = { error: null };
|
|
442
|
+
target.on("load-error", (e) => {
|
|
443
|
+
failure.error ??= e;
|
|
444
|
+
});
|
|
445
|
+
const failed = () => failure.error ? new Error(`render failed: ${failure.error.description} (code ${failure.error.code}) — ${failure.error.url}`) : null;
|
|
446
|
+
const warnings = [];
|
|
447
|
+
const warn = (message) => {
|
|
448
|
+
warnings.push(message);
|
|
449
|
+
human(message);
|
|
450
|
+
};
|
|
451
|
+
const applied = target.setViewport(spec.cssWidth, spec.cssHeight, spec.deviceScaleFactor);
|
|
452
|
+
let timer;
|
|
453
|
+
const timeout = new Promise((_, reject) => {
|
|
454
|
+
timer = setTimeout(() => reject(new Error(`load did not finish within ${options.timeoutMs} ms: ${url}`)), options.timeoutMs);
|
|
455
|
+
});
|
|
456
|
+
try {
|
|
457
|
+
await Promise.race([target.load(url), timeout]);
|
|
458
|
+
} finally {
|
|
459
|
+
clearTimeout(timer);
|
|
460
|
+
}
|
|
461
|
+
if (failure.error) {
|
|
462
|
+
throw new Error(`load failed: ${failure.error.description} (code ${failure.error.code}) — ${failure.error.url}`);
|
|
463
|
+
}
|
|
464
|
+
if (options.waitMs > 0) {
|
|
465
|
+
const until = Date.now() + options.waitMs;
|
|
466
|
+
while (Date.now() < until) {
|
|
467
|
+
const err = failed();
|
|
468
|
+
if (err) throw err;
|
|
469
|
+
await sleep(Math.min(50, until - Date.now()));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
let cssHeight = applied.height;
|
|
473
|
+
if (options.fullPage) {
|
|
474
|
+
const scrollHeight = Math.ceil(
|
|
475
|
+
await target.webContents.executeJavaScript(
|
|
476
|
+
"Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0)"
|
|
477
|
+
)
|
|
478
|
+
);
|
|
479
|
+
if (scrollHeight > cssHeight) {
|
|
480
|
+
const limit = targetSource.maxCssViewport(spec.deviceScaleFactor);
|
|
481
|
+
const wanted = Math.min(scrollHeight, limit);
|
|
482
|
+
if (scrollHeight > limit) {
|
|
483
|
+
warn(
|
|
484
|
+
`warning: full page is ${scrollHeight} CSS px tall; clamped to ${wanted} (device pixels are capped at 4096 per axis)`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
target.setViewport(applied.width, wanted, spec.deviceScaleFactor);
|
|
488
|
+
cssHeight = wanted;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
const frame = await captureQuiescent(target, { timeoutMs: options.timeoutMs, onWarn: warn, failure: failed });
|
|
492
|
+
return { frame, cssWidth: applied.width, cssHeight, warnings };
|
|
493
|
+
} finally {
|
|
494
|
+
target.destroy();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function snapOutputPath(cmd, spec) {
|
|
498
|
+
if (!cmd.matrix) return node_path.resolve(cmd.out);
|
|
499
|
+
if (cmd.out.includes("{preset}")) return node_path.resolve(cmd.out.replaceAll("{preset}", spec.presetId));
|
|
500
|
+
return node_path.resolve(cmd.out, `obsrv-${spec.presetId}.png`);
|
|
501
|
+
}
|
|
502
|
+
async function runSnap(cmd) {
|
|
503
|
+
const profile = targetSource.findProfile(cmd.profileId);
|
|
504
|
+
const results = [];
|
|
505
|
+
for (const spec of cmd.specs) {
|
|
506
|
+
const out = snapOutputPath(cmd, spec);
|
|
507
|
+
const r = await render(cmd.url, spec, cmd);
|
|
508
|
+
const img = applyPanelProfile(bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height), profile);
|
|
509
|
+
node_fs.mkdirSync(node_path.dirname(out), { recursive: true });
|
|
510
|
+
node_fs.writeFileSync(out, encodePng(img));
|
|
511
|
+
human(
|
|
512
|
+
`snap ${cmd.url} → ${out} (${r.frame.width}×${r.frame.height} device px, preset ${spec.presetId}, profile ${profile.id})`
|
|
513
|
+
);
|
|
514
|
+
results.push({
|
|
515
|
+
out,
|
|
516
|
+
preset: spec.presetId,
|
|
517
|
+
cssWidth: r.cssWidth,
|
|
518
|
+
cssHeight: r.cssHeight,
|
|
519
|
+
deviceScaleFactor: spec.deviceScaleFactor,
|
|
520
|
+
profile: profile.id,
|
|
521
|
+
// False means a best-effort capture of a page that never went
|
|
522
|
+
// paint-quiet (animation); machine consumers can gate on it.
|
|
523
|
+
settled: r.frame.settled,
|
|
524
|
+
warnings: r.warnings
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
await machine(cmd.matrix ? results : results[0]);
|
|
528
|
+
}
|
|
529
|
+
async function runDiff(cmd) {
|
|
530
|
+
const profile = targetSource.findProfile(cmd.profileId);
|
|
531
|
+
const t = await render(cmd.url, cmd.spec, { fullPage: false, waitMs: cmd.waitMs, timeoutMs: cmd.timeoutMs });
|
|
532
|
+
const target = applyPanelProfile(bgraToRgba(t.frame.bgra, t.frame.width, t.frame.height), profile);
|
|
533
|
+
const refSpec = { ...cmd.spec, deviceScaleFactor: 2 };
|
|
534
|
+
const r = await render(cmd.url, refSpec, {
|
|
535
|
+
fullPage: false,
|
|
536
|
+
waitMs: cmd.waitMs,
|
|
537
|
+
timeoutMs: cmd.timeoutMs,
|
|
538
|
+
mobileEmulation: false
|
|
539
|
+
});
|
|
540
|
+
const referenceFull = bgraToRgba(r.frame.bgra, r.frame.width, r.frame.height);
|
|
541
|
+
const referenceDeviceRows = inkRows(referenceFull);
|
|
542
|
+
const reference = boxDownsample(referenceFull, 2);
|
|
543
|
+
const metrics = diffMetrics(target, reference, referenceDeviceRows);
|
|
544
|
+
let files;
|
|
545
|
+
if (cmd.outDir) {
|
|
546
|
+
const dir = node_path.resolve(cmd.outDir);
|
|
547
|
+
node_fs.mkdirSync(dir, { recursive: true });
|
|
548
|
+
files = { target: node_path.join(dir, "target.png"), reference: node_path.join(dir, "reference.png") };
|
|
549
|
+
node_fs.writeFileSync(files.target, encodePng(target));
|
|
550
|
+
node_fs.writeFileSync(files.reference, encodePng(reference));
|
|
551
|
+
}
|
|
552
|
+
const pct2 = (v) => `${(v * 100).toFixed(2)}%`;
|
|
553
|
+
human(
|
|
554
|
+
`diff ${cmd.url} @ ${cmd.spec.presetId} (profile ${profile.id}): ink ${pct2(metrics.inkCoverage.target)} vs ${pct2(metrics.inkCoverage.reference)} reference, rows ${metrics.rows.target}/${metrics.rows.reference} (ratio ${metrics.rows.ratio?.toFixed(2) ?? "n/a"}), ${metrics.findings.length} finding(s)`
|
|
555
|
+
);
|
|
556
|
+
await machine({
|
|
557
|
+
url: cmd.url,
|
|
558
|
+
preset: cmd.spec.presetId,
|
|
559
|
+
profile: profile.id,
|
|
560
|
+
...files ? { files } : {},
|
|
561
|
+
...metrics
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
const userData = process.env.OBSRV_CLI_USER_DATA ?? node_fs.mkdtempSync(node_path.join(node_os.tmpdir(), "obsrv-cli-"));
|
|
565
|
+
electron.app.setPath("userData", userData);
|
|
566
|
+
const cleanupUserData = () => {
|
|
567
|
+
try {
|
|
568
|
+
node_fs.rmSync(userData, { recursive: true, force: true });
|
|
569
|
+
} catch {
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
electron.app.on("quit", cleanupUserData);
|
|
573
|
+
electron.app.on("window-all-closed", () => {
|
|
574
|
+
});
|
|
575
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
576
|
+
process.on(signal, () => {
|
|
577
|
+
cleanupUserData();
|
|
578
|
+
electron.app.exit(1);
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
void electron.app.whenReady().then(async () => {
|
|
582
|
+
electron.app.dock?.hide();
|
|
583
|
+
let code = 0;
|
|
584
|
+
try {
|
|
585
|
+
const cmd = parseArgs(cliArgv());
|
|
586
|
+
if (cmd.command === "help") {
|
|
587
|
+
await new Promise((done) => process.stdout.write(`${cmd.text}
|
|
588
|
+
`, () => done()));
|
|
589
|
+
} else if (cmd.command === "snap") {
|
|
590
|
+
await runSnap(cmd);
|
|
591
|
+
} else {
|
|
592
|
+
await runDiff(cmd);
|
|
593
|
+
}
|
|
594
|
+
} catch (e) {
|
|
595
|
+
code = e instanceof ArgError ? 2 : 1;
|
|
596
|
+
human(`obsrv: ${e instanceof Error ? e.message : String(e)}`);
|
|
597
|
+
}
|
|
598
|
+
cleanupUserData();
|
|
599
|
+
electron.app.exit(code);
|
|
600
|
+
});
|