inspectra-engine 0.0.0-stage → 1.0.2
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/CHANGELOG.md +58 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +132 -2
- package/bin/inspectra.mjs +68 -0
- package/client/index.d.mts +85 -0
- package/client/index.mjs +2 -0
- package/compare.js +439 -0
- package/dom-compare.js +263 -0
- package/examples/README.md +105 -0
- package/examples/figma-scan.json +12 -0
- package/examples/png-metadata.json +5 -0
- package/examples/runner-scan.json +7 -0
- package/node/analysis-worker.mjs +33 -0
- package/node/dev-config.mjs +18 -0
- package/node/event-stream.mjs +10 -0
- package/node/index.d.mts +114 -0
- package/node/index.mjs +100 -0
- package/node/job-manager.mjs +172 -0
- package/node/png-highlights.mjs +30 -0
- package/node/runner.mjs +135 -0
- package/package.json +136 -4
- package/pages.json +8 -0
- package/scan.js +27 -0
- package/scanner/README.md +70 -0
- package/scanner/accessibility.mjs +10 -0
- package/scanner/browser-snapshot.js +128 -0
- package/scanner/figma-snapshot.mjs +221 -0
- package/scanner/legacy-image-tools.cjs +14 -0
- package/scanner/ocr/local.mjs +52 -0
- package/scanner/ocr/vision.swift +29 -0
- package/scanner/png-regions.mjs +53 -0
- package/scanner/png-snapshot.mjs +192 -0
- package/scripts/check-build.mjs +12 -0
- package/server.js +361 -0
- package/ui/compare-2.html +946 -0
- package/ui/compare-3.html +1222 -0
- package/ui/compare.html +1408 -0
- package/ui/index.html +136 -0
- package/ui-check.js +1493 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Local runner example
|
|
2
|
+
|
|
3
|
+
1. Copy `.env.example` to `.env` if you do not already have a `.env` file.
|
|
4
|
+
2. Set `INSPECTRA_ALLOWED_ORIGINS` to your Visynq frontend's exact origin.
|
|
5
|
+
`http://localhost:5173` is the example frontend origin; this package does not
|
|
6
|
+
start that frontend. Leave the value empty for curl-only usage.
|
|
7
|
+
3. Start your baseline and target websites, then edit `runner-scan.json` to use
|
|
8
|
+
their real URLs and an explicit personal or company scope. Ports 3000 and 3001
|
|
9
|
+
are examples; Inspectra does not start those websites.
|
|
10
|
+
4. From the Inspectra directory, start the runner:
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
node bin/inspectra.mjs dev
|
|
14
|
+
# Or load a different configuration file:
|
|
15
|
+
node bin/inspectra.mjs dev --env .env.local
|
|
16
|
+
# After installing this version of the package:
|
|
17
|
+
npx inspectra dev
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Startup prints the runner URL, a generated token and its expiry. Leave that
|
|
21
|
+
terminal running. In a second terminal, set the values printed at startup:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
export RUNNER_URL='http://127.0.0.1:4317'
|
|
25
|
+
export RUNNER_TOKEN='paste-the-startup-token-here'
|
|
26
|
+
|
|
27
|
+
curl --fail-with-body -X POST "$RUNNER_URL/sessions" \
|
|
28
|
+
-H "Authorization: Bearer $RUNNER_TOKEN" \
|
|
29
|
+
-H 'Content-Type: application/json' \
|
|
30
|
+
--data-binary @examples/runner-scan.json
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The response contains a scan `id` and a new scan-specific `token`. Use those for
|
|
34
|
+
all scan endpoints. The startup token only creates sessions.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
export SCAN_ID='paste-the-session-id-here'
|
|
38
|
+
export SCAN_TOKEN='paste-the-session-token-here'
|
|
39
|
+
|
|
40
|
+
# Watch real scan events and issues until the scan finishes.
|
|
41
|
+
curl --no-buffer "$RUNNER_URL/sessions/$SCAN_ID/events" \
|
|
42
|
+
-H "Authorization: Bearer $SCAN_TOKEN"
|
|
43
|
+
|
|
44
|
+
# Check status.
|
|
45
|
+
curl "$RUNNER_URL/sessions/$SCAN_ID" \
|
|
46
|
+
-H "Authorization: Bearer $SCAN_TOKEN"
|
|
47
|
+
|
|
48
|
+
# Save the completed JSON report (409 if not completed yet).
|
|
49
|
+
curl --fail-with-body "$RUNNER_URL/sessions/$SCAN_ID/report" \
|
|
50
|
+
-H "Authorization: Bearer $SCAN_TOKEN" --output report.json
|
|
51
|
+
|
|
52
|
+
# Save the latest preview (204 means no frame yet).
|
|
53
|
+
curl "$RUNNER_URL/sessions/$SCAN_ID/preview" \
|
|
54
|
+
-H "Authorization: Bearer $SCAN_TOKEN" --output preview.jpg
|
|
55
|
+
|
|
56
|
+
# Cancel a running or queued scan; poll status until cancelled.
|
|
57
|
+
curl -X DELETE "$RUNNER_URL/sessions/$SCAN_ID" \
|
|
58
|
+
-H "Authorization: Bearer $SCAN_TOKEN"
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`INSPECTRA_PORT` is the runner port; `INSPECTRA_ALLOWED_ORIGINS` describes the
|
|
62
|
+
frontend allowed to call it. Baseline and target URLs describe the websites
|
|
63
|
+
being scanned. These are three separate settings.
|
|
64
|
+
|
|
65
|
+
Shell variables override `.env`; `--port` and repeated `--origin` flags override
|
|
66
|
+
both. Invalid values fail at startup. An absent default `.env` is allowed; an
|
|
67
|
+
explicit `--env` must exist. The Node API does not automatically read `.env`.
|
|
68
|
+
|
|
69
|
+
Use Ctrl+C in the runner terminal to cancel work and clean up temporary reports.
|
|
70
|
+
Download results before expiry or shutdown. `.env` is ignored by Git and excluded
|
|
71
|
+
from npm packages. Do not save runtime tokens in configuration files.
|
|
72
|
+
|
|
73
|
+
## Figma baseline
|
|
74
|
+
|
|
75
|
+
Use `examples/figma-scan.json` as the session request instead of
|
|
76
|
+
`examples/runner-scan.json`. Replace the file URL and selected node ID with real
|
|
77
|
+
values and set the actual website URL. Set `FIGMA_TOKEN` in the local Node runner
|
|
78
|
+
before starting `dev`; React requests contain no Figma token. The token needs
|
|
79
|
+
`file_content:read` and access to that file. The baseline PNG is Figma's rendering;
|
|
80
|
+
issues come from the existing Shivax snapshot comparator. See the Phase 10 section
|
|
81
|
+
in the root README for scaling, role mappings, font and unsupported-property
|
|
82
|
+
limitations. No original image assets or fonts are installed into your website.
|
|
83
|
+
|
|
84
|
+
## PNG baseline
|
|
85
|
+
|
|
86
|
+
Record the viewport and DPR **when taking the screenshot**. Edit
|
|
87
|
+
`examples/png-metadata.json` to match those values, then run:
|
|
88
|
+
|
|
89
|
+
```sh
|
|
90
|
+
node bin/inspectra.mjs compare --baseline /absolute/path/reference.png \
|
|
91
|
+
--png-metadata examples/png-metadata.json --target http://localhost:3000 \
|
|
92
|
+
--output ./new-png-report --scope-kind personal --scope-id png-demo
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The target uses that viewport by default. The output includes the original
|
|
96
|
+
baseline, a real target screenshot, a JSON report, and `baseline.matches.svg` /
|
|
97
|
+
`target.matches.svg` with corresponding matched regions highlighted. Open these
|
|
98
|
+
SVGs locally; matches and differences from PNGs require human review.
|
|
99
|
+
|
|
100
|
+
OCR runs locally through macOS Vision and Swift command-line tools on macOS, or
|
|
101
|
+
an installed Tesseract executable with English data on other systems. There is
|
|
102
|
+
no hosted OCR requirement and no silent placeholder output if OCR is unavailable.
|
|
103
|
+
Node callers can supply `pngOcr` for another OCR implementation. See the Phase 11
|
|
104
|
+
section in the root README for inline HTTP uploads, confidence, limitations and
|
|
105
|
+
unknown CSS properties.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"baseline": {
|
|
3
|
+
"type": "figma",
|
|
4
|
+
"url": "https://www.figma.com/design/REPLACE_FILE_KEY/Design",
|
|
5
|
+
"nodeId": "123:456",
|
|
6
|
+
"unitsPerCssPixel": 1
|
|
7
|
+
},
|
|
8
|
+
"target": "http://localhost:3000",
|
|
9
|
+
"scope": { "kind": "personal", "id": "figma-demo" },
|
|
10
|
+
"viewport": { "width": 1440, "height": 900 },
|
|
11
|
+
"accessibility": true
|
|
12
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
|
|
2
|
+
import { analyzeUI } from 'shivax';
|
|
3
|
+
|
|
4
|
+
if (!isMainThread) {
|
|
5
|
+
try {
|
|
6
|
+
const result = analyzeUI(...workerData, state => parentPort.postMessage({ type: 'state', state }));
|
|
7
|
+
parentPort.postMessage({ type: 'result', result });
|
|
8
|
+
} catch (error) { parentPort.postMessage({ type: 'error', message: error.message }); }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function analyzeInWorker(baseline, target, options, signal, onEvent) {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
const worker = new Worker(new URL(import.meta.url), { workerData: [baseline, target, options] });
|
|
14
|
+
let abort;
|
|
15
|
+
try {
|
|
16
|
+
return await new Promise((resolve, reject) => {
|
|
17
|
+
abort = () => reject(signal.reason);
|
|
18
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
19
|
+
if (signal?.aborted) return abort();
|
|
20
|
+
worker.on('message', event => {
|
|
21
|
+
if (signal?.aborted) return;
|
|
22
|
+
if (event.type === 'result') resolve(event.result);
|
|
23
|
+
else if (event.type === 'error') reject(new Error(event.message));
|
|
24
|
+
else { try { onEvent?.(event); } catch (error) { reject(error); } }
|
|
25
|
+
});
|
|
26
|
+
worker.on('error', reject);
|
|
27
|
+
worker.on('exit', code => reject(new Error(`Analysis worker exited before result (${code})`)));
|
|
28
|
+
});
|
|
29
|
+
} finally {
|
|
30
|
+
signal?.removeEventListener('abort', abort);
|
|
31
|
+
await worker.terminate();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** CLI configuration only. Importing inspectra/node never loads a project's .env. */
|
|
2
|
+
export function devOptions(values, env = process.env) {
|
|
3
|
+
const number = (value, name) => {
|
|
4
|
+
if (value === undefined) return undefined;
|
|
5
|
+
if (!/^\d+$/.test(value)) throw new TypeError(`${name} must be an integer`);
|
|
6
|
+
return Number(value);
|
|
7
|
+
};
|
|
8
|
+
const settings = {
|
|
9
|
+
port: number(values.port ?? env.INSPECTRA_PORT, 'port'),
|
|
10
|
+
allowedOrigins: values.origin ?? (env.INSPECTRA_ALLOWED_ORIGINS ?? '').split(',').map(origin => origin.trim()).filter(Boolean),
|
|
11
|
+
};
|
|
12
|
+
for (const [key, variable] of Object.entries({
|
|
13
|
+
concurrency: 'INSPECTRA_CONCURRENCY', maxQueued: 'INSPECTRA_MAX_QUEUED', maxSessions: 'INSPECTRA_MAX_SESSIONS',
|
|
14
|
+
scanTimeout: 'INSPECTRA_SCAN_TIMEOUT_MS', captureTimeout: 'INSPECTRA_CAPTURE_TIMEOUT_MS', bootstrapTTL: 'INSPECTRA_BOOTSTRAP_TTL_MS',
|
|
15
|
+
tokenTTL: 'INSPECTRA_SESSION_TTL_MS', previewInterval: 'INSPECTRA_PREVIEW_INTERVAL_MS',
|
|
16
|
+
})) settings[key] = number(env[variable], variable);
|
|
17
|
+
return settings;
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// write(false) means bytes were accepted into Node's buffer, not rejected.
|
|
2
|
+
// Preserve ordinary backpressure; disconnect only subscribers exceeding a bound.
|
|
3
|
+
export function sendEvent(res, data, end = false) {
|
|
4
|
+
if (res.destroyed || res.writableEnded) return;
|
|
5
|
+
if (data) {
|
|
6
|
+
res.write(data);
|
|
7
|
+
if (res.writableLength > 2 * 1024 * 1024) { res.destroy(); return; }
|
|
8
|
+
}
|
|
9
|
+
if (end) res.end();
|
|
10
|
+
}
|
package/node/index.d.mts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { Page } from 'playwright';
|
|
2
|
+
import type { UISnapshot, UIAnalysisOptions } from 'shivax';
|
|
3
|
+
import type { ComparisonResult, FigmaBaseline, PngBaseline } from '../client/index.mjs';
|
|
4
|
+
type WithoutEnvelope<T> = T extends unknown ? Omit<T, 'id' | 'sessionId' | 'timestamp'> : never;
|
|
5
|
+
export type CaptureEvent = WithoutEnvelope<import('../client/index.mjs').ScanEvent>;
|
|
6
|
+
export interface CaptureOptions {
|
|
7
|
+
scope: UISnapshot['scope'];
|
|
8
|
+
uiChecks?: boolean;
|
|
9
|
+
spelling?: boolean;
|
|
10
|
+
maxNodes?: number;
|
|
11
|
+
includeText?: boolean;
|
|
12
|
+
timeout?: number;
|
|
13
|
+
ignoreSelectors?: string[];
|
|
14
|
+
requiredAssets?: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface PageCapture {
|
|
17
|
+
snapshot: UISnapshot;
|
|
18
|
+
metadata: Record<string, unknown>;
|
|
19
|
+
screenshot?: { path: string; width: number; height: number };
|
|
20
|
+
accessibility?: unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface PairOptions extends CaptureOptions {
|
|
23
|
+
baselineUrl: string;
|
|
24
|
+
targetUrl: string;
|
|
25
|
+
viewport?: { width: number; height: number };
|
|
26
|
+
deviceScaleFactor?: number;
|
|
27
|
+
signal?: AbortSignal;
|
|
28
|
+
accessibility?: boolean;
|
|
29
|
+
screenshots?: { baseline?: string; target?: string };
|
|
30
|
+
onEvent?: (event: CaptureEvent) => void;
|
|
31
|
+
onPage?: (page: Page, side: 'baseline' | 'target') => void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>;
|
|
32
|
+
}
|
|
33
|
+
export interface CompareOptions extends CaptureOptions {
|
|
34
|
+
baseline: string | FigmaBaseline | PngBaseline | PngFileBaseline;
|
|
35
|
+
target: string;
|
|
36
|
+
output: string;
|
|
37
|
+
viewport?: { width: number; height: number };
|
|
38
|
+
deviceScaleFactor?: number;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
analysisOptions?: UIAnalysisOptions;
|
|
41
|
+
analysisWorker?: boolean;
|
|
42
|
+
/** Node-only PAT; defaults to process.env.FIGMA_TOKEN. Never put this in ScanRequest. */
|
|
43
|
+
figmaToken?: string;
|
|
44
|
+
/** Injectable REST boundary for controlled integration tests. */
|
|
45
|
+
figmaFetch?: typeof fetch;
|
|
46
|
+
pngOcr?: PngOCRProvider;
|
|
47
|
+
onEvent?: PairOptions['onEvent'];
|
|
48
|
+
onPage?: PairOptions['onPage'];
|
|
49
|
+
accessibility?: boolean;
|
|
50
|
+
}
|
|
51
|
+
export declare function compare(options: CompareOptions): Promise<ComparisonResult>;
|
|
52
|
+
export declare function captureUISnapshot(page: Page, options: CaptureOptions): Promise<UISnapshot>;
|
|
53
|
+
export declare function captureUIPage(page: Page, options: CaptureOptions & { screenshotPath?: string }): Promise<PageCapture>;
|
|
54
|
+
export declare function captureUISnapshotPair(options: PairOptions): Promise<{ baseline: PageCapture; target: PageCapture }>;
|
|
55
|
+
export declare function scanAccessibility(page: Page, options?: { exclude?: string[]; bestPractice?: boolean }): Promise<unknown>;
|
|
56
|
+
export declare function runUiCheck(options?: Record<string, unknown>): Promise<unknown>;
|
|
57
|
+
|
|
58
|
+
export interface PreviewFrame {
|
|
59
|
+
sessionId: string;
|
|
60
|
+
scope: UISnapshot['scope'];
|
|
61
|
+
data: Uint8Array;
|
|
62
|
+
contentType: 'image/jpeg';
|
|
63
|
+
signal: AbortSignal;
|
|
64
|
+
side: 'baseline' | 'target';
|
|
65
|
+
timestamp: string;
|
|
66
|
+
}
|
|
67
|
+
export interface LocalRunnerOptions {
|
|
68
|
+
port?: number;
|
|
69
|
+
allowedOrigins?: string[];
|
|
70
|
+
bootstrapTTL?: number;
|
|
71
|
+
tokenTTL?: number;
|
|
72
|
+
concurrency?: number;
|
|
73
|
+
maxQueued?: number;
|
|
74
|
+
maxSessions?: number;
|
|
75
|
+
scanTimeout?: number;
|
|
76
|
+
/** Default per-capture timeout; bounded by scanTimeout. Explicit scan requests may override it. */
|
|
77
|
+
captureTimeout?: number;
|
|
78
|
+
previewInterval?: number;
|
|
79
|
+
figmaToken?: string;
|
|
80
|
+
figmaFetch?: typeof fetch;
|
|
81
|
+
pngOcr?: PngOCRProvider;
|
|
82
|
+
frameTransport?: (frame: PreviewFrame) => void | Promise<void>;
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
}
|
|
85
|
+
export declare function startLocalRunner(options?: LocalRunnerOptions): Promise<{
|
|
86
|
+
origin: string; token: string; expiresAt: number; close(): Promise<void>;
|
|
87
|
+
}>;
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
export interface FigmaCapture extends PageCapture { preview: Uint8Array }
|
|
91
|
+
export declare function figmaNodeToSnapshot(entry: unknown, options: {
|
|
92
|
+
source: FigmaBaseline; scope: UISnapshot['scope']; viewport?: UISnapshot['viewport']; maxNodes?: number; includeText?: boolean;
|
|
93
|
+
}): PageCapture;
|
|
94
|
+
export declare function captureFigmaBaseline(source: FigmaBaseline, options: {
|
|
95
|
+
scope: UISnapshot['scope']; token?: string; fetch?: typeof fetch; signal?: AbortSignal;
|
|
96
|
+
timeout?: number; viewport?: UISnapshot['viewport']; maxNodes?: number; includeText?: boolean;
|
|
97
|
+
screenshotPath?: string; onEvent?: PairOptions['onEvent'];
|
|
98
|
+
}): Promise<FigmaCapture>;
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
export type PngFileBaseline = Omit<PngBaseline, 'data'> & { path: string };
|
|
102
|
+
export interface PngObservations {
|
|
103
|
+
provider: string;
|
|
104
|
+
lines: { text: string; bounds: { x: number; y: number; width: number; height: number }; confidence: number }[];
|
|
105
|
+
regions?: { kind: 'surface' | 'image'; bounds: { x: number; y: number; width: number; height: number }; confidence: number; method: string }[];
|
|
106
|
+
}
|
|
107
|
+
export type PngOCRProvider = (input: { path: string; width: number; height: number; signal: AbortSignal; timeout: number; scope: UISnapshot['scope'] }) => PngObservations | Promise<PngObservations>;
|
|
108
|
+
export declare function pngObservationsToSnapshot(observations: PngObservations, options: {
|
|
109
|
+
source: PngBaseline | PngFileBaseline; scope: UISnapshot['scope']; width: number; height: number; maxNodes?: number; includeText?: boolean;
|
|
110
|
+
}): PageCapture;
|
|
111
|
+
export declare function capturePNGBaseline(source: PngBaseline | PngFileBaseline, options: {
|
|
112
|
+
scope: UISnapshot['scope']; signal?: AbortSignal; timeout?: number; pngOcr?: PngOCRProvider;
|
|
113
|
+
maxNodes?: number; includeText?: boolean; screenshotPath?: string; onEvent?: PairOptions['onEvent'];
|
|
114
|
+
}): Promise<PageCapture>;
|
package/node/index.mjs
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { analyzeUI, validateUIAnalysisOptions } from 'shivax';
|
|
4
|
+
import { capturePNGBaseline, validatePNGBaseline } from '../scanner/png-snapshot.mjs';
|
|
5
|
+
export { capturePNGBaseline, pngObservationsToSnapshot } from '../scanner/png-snapshot.mjs';
|
|
6
|
+
import { captureFigmaBaseline, validateFigmaBaseline } from '../scanner/figma-snapshot.mjs';
|
|
7
|
+
export { captureFigmaBaseline, figmaNodeToSnapshot } from '../scanner/figma-snapshot.mjs';
|
|
8
|
+
import { captureUISnapshotPair } from '../scanner/browser-snapshot.js';
|
|
9
|
+
export { captureUISnapshot, captureUIPage, captureUISnapshotPair } from '../scanner/browser-snapshot.js';
|
|
10
|
+
export { scanAccessibility } from '../scanner/accessibility.mjs';
|
|
11
|
+
export { runUiCheck } from '../ui-check.js';
|
|
12
|
+
|
|
13
|
+
/** Local capture and Shivax analysis. A fresh output directory prevents scope mixing. */
|
|
14
|
+
export async function compare({ baseline, target, viewport, output,
|
|
15
|
+
scope, signal, analysisOptions = {}, accessibility = true, onEvent, onPage, analysisWorker = false, uiChecks = false, spelling = false, figmaToken, figmaFetch, pngOcr, ...captureOptions } = {}) {
|
|
16
|
+
const isFigma = baseline?.type === 'figma', isPng = baseline?.type === 'png';
|
|
17
|
+
if (isPng) validatePNGBaseline(baseline);
|
|
18
|
+
viewport ??= isPng ? { width: baseline.viewport.width, height: baseline.viewport.height } : { width: 1440, height: 900 };
|
|
19
|
+
if (isFigma) validateFigmaBaseline(baseline);
|
|
20
|
+
for (const [key, value] of Object.entries(isFigma || isPng ? { target } : { baseline, target })) {
|
|
21
|
+
let url;
|
|
22
|
+
try { url = new URL(value); } catch { throw new TypeError(`${key} must be an absolute URL`); }
|
|
23
|
+
if (!['http:', 'https:', 'file:'].includes(url.protocol)) throw new TypeError(`${key} must use http, https or file`);
|
|
24
|
+
}
|
|
25
|
+
if (typeof output !== 'string' || !output.trim()) throw new TypeError('output directory is required');
|
|
26
|
+
if (typeof accessibility !== 'boolean') throw new TypeError('accessibility must be boolean');
|
|
27
|
+
if (typeof uiChecks !== 'boolean' || typeof spelling !== 'boolean') throw new TypeError('uiChecks and spelling must be boolean');
|
|
28
|
+
if ((isFigma || isPng) && uiChecks) throw new TypeError('Legacy uiChecks require URL baselines; image/design sources use Shivax analysis');
|
|
29
|
+
if (spelling && !uiChecks) throw new TypeError('spelling requires uiChecks');
|
|
30
|
+
if (uiChecks && captureOptions.includeText === false) throw new TypeError('UI checks require includeText');
|
|
31
|
+
validateUIAnalysisOptions(analysisOptions);
|
|
32
|
+
signal?.throwIfAborted();
|
|
33
|
+
// Validate capture configuration before creating any artifacts.
|
|
34
|
+
const { validateCaptureOptions } = await import('../scanner/browser-snapshot.js');
|
|
35
|
+
validateCaptureOptions({ ...captureOptions, scope, viewport });
|
|
36
|
+
const destination = path.resolve(output);
|
|
37
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
38
|
+
// Exclusive reservation: never overwrite or mix an earlier company's report.
|
|
39
|
+
await mkdir(destination, { mode: 0o700 });
|
|
40
|
+
let staging;
|
|
41
|
+
try {
|
|
42
|
+
staging = await mkdtemp(path.join(destination, '.capture-'));
|
|
43
|
+
const baselineCapture = isPng ? await capturePNGBaseline(baseline, {
|
|
44
|
+
...captureOptions, pngOcr, scope, signal, onEvent, screenshotPath: path.join(staging, 'baseline.png') }) : isFigma ? await captureFigmaBaseline(baseline, {
|
|
45
|
+
...captureOptions, token: figmaToken, fetch: figmaFetch, signal, scope, onEvent,
|
|
46
|
+
viewport: { ...viewport, devicePixelRatio: captureOptions.deviceScaleFactor ?? 1 }, screenshotPath: path.join(staging, 'baseline.png') }) : undefined;
|
|
47
|
+
const pair = await captureUISnapshotPair({ ...captureOptions, baselineUrl: baseline, targetUrl: target,
|
|
48
|
+
viewport, scope, signal, accessibility, onEvent, onPage, uiChecks, baselineCapture,
|
|
49
|
+
screenshots: { baseline: path.join(staging, 'baseline.png'), target: path.join(staging, 'target.png') } });
|
|
50
|
+
if (isFigma) {
|
|
51
|
+
// Compare the same declared primary family; do not pretend to know the
|
|
52
|
+
// glyph font actually selected by the browser's fallback mechanism.
|
|
53
|
+
for (const node of pair.target.snapshot.nodes) {
|
|
54
|
+
const family = node.typography?.fontFamily;
|
|
55
|
+
if (family?.status !== 'known') continue;
|
|
56
|
+
const match = /^(?:"([^"]+)"|'([^']+)'|([^,]+))/.exec(family.value);
|
|
57
|
+
if (match) {
|
|
58
|
+
const primary = (match[1] ?? match[2] ?? match[3]).trim();
|
|
59
|
+
pair.target.metadata.nodes[node.id].declaredFontStack = family.value;
|
|
60
|
+
family.value = primary;
|
|
61
|
+
family.provenance.method = 'Primary declared CSS font family; actual glyph font unverified';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
const analysis = analysisWorker
|
|
67
|
+
? await (await import('./analysis-worker.mjs')).analyzeInWorker(pair.baseline.snapshot, pair.target.snapshot, analysisOptions, signal, onEvent)
|
|
68
|
+
: analyzeUI(pair.baseline.snapshot, pair.target.snapshot, analysisOptions, state => onEvent?.({ type: 'state', state }));
|
|
69
|
+
if (isPng) for (const issue of analysis.issues) issue.reviewStatus = 'needs-review';
|
|
70
|
+
for (const issue of analysis.issues) onEvent?.({ type: 'issue', issue, ...(isPng ? { status: 'needs_review' } : {}) });
|
|
71
|
+
onEvent?.({ type: 'analysis', diagnostics: analysis.diagnostics, work: analysis.work });
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
let uiCheck;
|
|
74
|
+
if (uiChecks) {
|
|
75
|
+
const { collectUICheck, checkSpelling } = await import('../ui-check.js');
|
|
76
|
+
const groups = collectUICheck(pair.baseline.uiInspection, pair.target.uiInspection);
|
|
77
|
+
const words = spelling ? await checkSpelling(pair.target.uiInspection.texts, [], signal) : [];
|
|
78
|
+
signal?.throwIfAborted();
|
|
79
|
+
uiCheck = { ...groups, spelling: words, spellingRan: spelling, provenance: { method: 'inspectra-ui-check', kind: 'inferred', confidence: null } };
|
|
80
|
+
}
|
|
81
|
+
const pngMatches = isPng ? await (await import('./png-highlights.mjs')).writePNGHighlights(pair, analysis, staging, signal) : undefined;
|
|
82
|
+
const review = isPng ? { status: 'needs_review', issues: analysis.issues.map(issue => ({ id: issue.id, status: 'needs_review' })), uncertain: analysis.matching.uncertain, uncertainTarget: analysis.matching.uncertainTarget } : undefined;
|
|
83
|
+
const result = { ...(isPng ? { review, matches: pngMatches } : {}), ...(uiCheck ? { uiCheck } : {}), version: 1, scope: pair.baseline.snapshot.scope,
|
|
84
|
+
baseline: pair.baseline.snapshot, target: pair.target.snapshot, analysis, issues: analysis.issues,
|
|
85
|
+
evidence: { baseline: pair.baseline.metadata, target: pair.target.metadata },
|
|
86
|
+
accessibility: accessibility ? { baseline: pair.baseline.accessibility ?? null, target: pair.target.accessibility } : null,
|
|
87
|
+
artifacts: { ...(isPng ? { baselineHighlights: path.join(destination, 'baseline.matches.svg'), targetHighlights: path.join(destination, 'target.matches.svg') } : {}), report: path.join(destination, 'report.json'),
|
|
88
|
+
baselineScreenshot: path.join(destination, 'baseline.png'), targetScreenshot: path.join(destination, 'target.png') } };
|
|
89
|
+
await writeFile(path.join(staging, 'report.json'), JSON.stringify(result, null, 2) + '\n', { mode: 0o600 });
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
for (const name of ['baseline.png', 'target.png', 'report.json', ...(isPng ? ['baseline.matches.svg', 'target.matches.svg'] : [])]) await rename(path.join(staging, name), path.join(destination, name));
|
|
92
|
+
await rm(staging, { recursive: true });
|
|
93
|
+
return result;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await rm(destination, { recursive: true, force: true });
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export { startLocalRunner } from './runner.mjs';
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { compare } from './index.mjs';
|
|
5
|
+
import { validateCaptureOptions } from '../scanner/browser-snapshot.js';
|
|
6
|
+
import { validatePNGBaseline } from '../scanner/png-snapshot.mjs';
|
|
7
|
+
import { validateFigmaBaseline } from '../scanner/figma-snapshot.mjs';
|
|
8
|
+
import { validateUIAnalysisOptions } from 'shivax';
|
|
9
|
+
|
|
10
|
+
export const terminal = state => ['completed', 'failed', 'cancelled'].includes(state);
|
|
11
|
+
export const secret = () => randomBytes(32).toString('base64url');
|
|
12
|
+
export function integer(value, name, min, max) {
|
|
13
|
+
if (!Number.isInteger(value) || value < min || value > max) throw new TypeError(`${name} must be ${min}..${max}`);
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ScanJobs {
|
|
18
|
+
constructor({ root, concurrency = 1, maxQueued = 4, maxSessions = 16, tokenTTL = 900000,
|
|
19
|
+
scanTimeout = 60000, captureTimeout, previewInterval = 1000, frameTransport, figmaToken, figmaFetch, pngOcr } = {}) {
|
|
20
|
+
this.root = root; this.figmaToken = figmaToken; this.figmaFetch = figmaFetch; this.pngOcr = pngOcr;
|
|
21
|
+
this.concurrency = integer(concurrency, 'concurrency', 1, 4);
|
|
22
|
+
this.maxQueued = integer(maxQueued, 'maxQueued', 0, 32);
|
|
23
|
+
this.maxSessions = integer(maxSessions, 'maxSessions', 1, 64);
|
|
24
|
+
this.tokenTTL = integer(tokenTTL, 'tokenTTL', 100, 900000);
|
|
25
|
+
this.scanTimeout = integer(scanTimeout, 'scanTimeout', 100, 300000);
|
|
26
|
+
this.captureTimeout = integer(captureTimeout ?? Math.min(15000, this.scanTimeout), 'captureTimeout', 1, this.scanTimeout);
|
|
27
|
+
this.previewInterval = integer(previewInterval, 'previewInterval', 250, 10000);
|
|
28
|
+
if (frameTransport !== undefined && typeof frameTransport !== 'function') throw new TypeError('frameTransport must be a function');
|
|
29
|
+
this.frameTransport = frameTransport;
|
|
30
|
+
this.jobs = new Map(); this.queue = []; this.active = new Set(); this.closed = false;
|
|
31
|
+
this.sweeper = setInterval(() => { void this.sweep(); }, 1000); this.sweeper.unref();
|
|
32
|
+
}
|
|
33
|
+
create(input) {
|
|
34
|
+
if (this.closed) throw Object.assign(new Error('Runner is shutting down'), { status: 503 });
|
|
35
|
+
if (this.jobs.size >= this.maxSessions || (this.active.size >= this.concurrency && this.queue.length >= this.maxQueued)) {
|
|
36
|
+
throw Object.assign(new Error('Scan capacity reached'), { status: 429 });
|
|
37
|
+
}
|
|
38
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('A scan object is required');
|
|
39
|
+
const allowed = ['baseline', 'target', 'scope', 'viewport', 'timeout', 'maxNodes', 'includeText', 'ignoreSelectors', 'requiredAssets', 'analysisOptions', 'accessibility', 'uiChecks', 'spelling'];
|
|
40
|
+
if (Object.keys(input).some(key => !allowed.includes(key))) throw new TypeError('Unsupported scan option');
|
|
41
|
+
const options = structuredClone({ viewport: { width: 1440, height: 900 }, timeout: this.captureTimeout, maxNodes: 2000, ...input });
|
|
42
|
+
for (const key of ['baseline', 'target']) {
|
|
43
|
+
if (key === 'baseline' && options.baseline?.type === 'png') {
|
|
44
|
+
validatePNGBaseline(options.baseline, { allowPath: false });
|
|
45
|
+
if (options.baseline.data.length > 4 * 1024 * 1024 * 4 / 3 + 32) throw Object.assign(new Error('Inline PNG exceeds 4 MiB upload limit'), { status: 413 });
|
|
46
|
+
if (input.viewport === undefined) options.viewport = { width: options.baseline.viewport.width, height: options.baseline.viewport.height };
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (key === 'baseline' && options.baseline?.type === 'figma') { validateFigmaBaseline(options.baseline); continue; }
|
|
50
|
+
const url = new URL(options[key]);
|
|
51
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new TypeError(`${key} requires an HTTP(S) URL without credentials`);
|
|
52
|
+
}
|
|
53
|
+
validateCaptureOptions(options); validateUIAnalysisOptions(options.analysisOptions ?? {});
|
|
54
|
+
if (options.viewport.width > 3840 || options.viewport.height > 2160 || options.maxNodes > 2000 || options.timeout > this.scanTimeout) throw new TypeError('Scan resource limit exceeded');
|
|
55
|
+
if (options.accessibility !== undefined && typeof options.accessibility !== 'boolean') throw new TypeError('accessibility must be boolean');
|
|
56
|
+
for (const key of ['uiChecks', 'spelling']) if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new TypeError(`${key} must be boolean`);
|
|
57
|
+
if (['figma', 'png'].includes(options.baseline?.type) && options.uiChecks) throw new TypeError('Legacy uiChecks require URL baselines');
|
|
58
|
+
if (options.spelling && !options.uiChecks) throw new TypeError('spelling requires uiChecks');
|
|
59
|
+
if (options.uiChecks && options.includeText === false) throw new TypeError('UI checks require includeText');
|
|
60
|
+
options.analysisOptions = { ...options.analysisOptions,
|
|
61
|
+
maxWork: Math.min(options.analysisOptions?.maxWork ?? 250000, 500000),
|
|
62
|
+
matchingOptions: { ...options.analysisOptions?.matchingOptions,
|
|
63
|
+
maxWork: Math.min(options.analysisOptions?.matchingOptions?.maxWork ?? 1000000, 1000000) } };
|
|
64
|
+
const job = { id: randomUUID(), token: secret(), expiresAt: Date.now() + this.tokenTTL, options,
|
|
65
|
+
state: 'queued', events: [], eventBytes: 0, sequence: 0, listeners: new Set(), controller: new AbortController(),
|
|
66
|
+
frame: null, result: null, error: null, done: null };
|
|
67
|
+
job.output = path.join(this.root, job.id);
|
|
68
|
+
this.jobs.set(job.id, job); this.queue.push(job); this.emit(job, { type: 'state', state: 'queued' }); this.pump();
|
|
69
|
+
return job;
|
|
70
|
+
}
|
|
71
|
+
status(job) { return { id: job.id, state: job.state, scope: job.options.scope, expiresAt: job.expiresAt, error: job.error, lastEventId: job.sequence }; }
|
|
72
|
+
emit(job, event) {
|
|
73
|
+
if (terminal(job.state)) return;
|
|
74
|
+
if (event.type === 'state') job.state = event.state;
|
|
75
|
+
const item = { ...event, id: ++job.sequence, sessionId: job.id, timestamp: new Date().toISOString() };
|
|
76
|
+
const data = `id: ${item.id}\nevent: ${item.type}\ndata: ${JSON.stringify(item)}\n\n`;
|
|
77
|
+
job.events.push({ id: item.id, data }); job.eventBytes += Buffer.byteLength(data);
|
|
78
|
+
while (job.events.length > 512 || job.eventBytes > 1048576) job.eventBytes -= Buffer.byteLength(job.events.shift().data);
|
|
79
|
+
for (const listener of job.listeners) listener(data, terminal(job.state));
|
|
80
|
+
}
|
|
81
|
+
pump() {
|
|
82
|
+
while (!this.closed && this.active.size < this.concurrency && this.queue.length) {
|
|
83
|
+
const job = this.queue.shift();
|
|
84
|
+
if (terminal(job.state)) continue;
|
|
85
|
+
if (Date.now() >= job.expiresAt) { this.cancel(job); continue; }
|
|
86
|
+
this.active.add(job);
|
|
87
|
+
job.done = this.run(job).finally(() => { this.active.delete(job); this.pump(); });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async run(job) {
|
|
91
|
+
const timer = setTimeout(() => job.controller.abort(new DOMException(`Scan timed out after ${this.scanTimeout} ms; adjust INSPECTRA_SCAN_TIMEOUT_MS in the runner environment`, 'TimeoutError')), this.scanTimeout);
|
|
92
|
+
try {
|
|
93
|
+
job.result = await compare({ ...job.options, output: job.output, signal: job.controller.signal,
|
|
94
|
+
analysisWorker: true, figmaToken: this.figmaToken, figmaFetch: this.figmaFetch, pngOcr: this.pngOcr, onEvent: event => this.emit(job, event), onPage: (page, side) => this.preview(job, page, side) });
|
|
95
|
+
job.controller.signal.throwIfAborted();
|
|
96
|
+
this.emit(job, { type: 'state', state: 'completed' });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
job.error = { name: error.name, message: error.message };
|
|
99
|
+
job.frame = null;
|
|
100
|
+
this.emit(job, { type: 'state', state: job.controller.signal.aborted && error.name !== 'TimeoutError' ? 'cancelled' : 'failed', error: job.error });
|
|
101
|
+
} finally { clearTimeout(timer); }
|
|
102
|
+
}
|
|
103
|
+
preview(job, page, side) {
|
|
104
|
+
let stopped = false, ready = false, delivered = false, lastCapture = 0, inFlight;
|
|
105
|
+
const capture = () => {
|
|
106
|
+
if (stopped || !ready || inFlight || job.controller.signal.aborted || Date.now() - lastCapture < this.previewInterval) return;
|
|
107
|
+
lastCapture = Date.now();
|
|
108
|
+
inFlight = (async () => {
|
|
109
|
+
try {
|
|
110
|
+
const data = await page.screenshot({ type: 'jpeg', quality: 55, timeout: 2000, animations: 'allow' });
|
|
111
|
+
if (stopped || job.controller.signal.aborted || data.length > 524288) return;
|
|
112
|
+
const frame = { data, contentType: 'image/jpeg', side, timestamp: new Date().toISOString() };
|
|
113
|
+
// A transport callback replaces in-memory delivery; HTTP preview then returns 204.
|
|
114
|
+
if (this.frameTransport) await this.deliverFrame(job, frame);
|
|
115
|
+
else job.frame = frame;
|
|
116
|
+
delivered = true;
|
|
117
|
+
this.emit(job, { type: 'preview', side, timestamp: frame.timestamp, bytes: data.length });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!stopped && !job.controller.signal.aborted) this.emit(job, { type: 'preview-error', side, message: error.message });
|
|
120
|
+
} finally { inFlight = null; }
|
|
121
|
+
})();
|
|
122
|
+
};
|
|
123
|
+
const loaded = () => { ready = true; capture(); };
|
|
124
|
+
page.on('domcontentloaded', loaded);
|
|
125
|
+
const timer = setInterval(capture, this.previewInterval); timer.unref();
|
|
126
|
+
return async () => {
|
|
127
|
+
clearInterval(timer); page.off('domcontentloaded', loaded);
|
|
128
|
+
await inFlight;
|
|
129
|
+
if (!delivered && !job.controller.signal.aborted) { capture(); await inFlight; }
|
|
130
|
+
stopped = true;
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
async deliverFrame(job, frame) {
|
|
134
|
+
const signal = job.controller.signal;
|
|
135
|
+
let timer, abort;
|
|
136
|
+
try {
|
|
137
|
+
await Promise.race([
|
|
138
|
+
Promise.resolve().then(() => this.frameTransport({ sessionId: job.id, scope: job.options.scope, ...frame, signal })),
|
|
139
|
+
new Promise((_, reject) => {
|
|
140
|
+
abort = () => reject(signal.reason);
|
|
141
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
142
|
+
if (signal.aborted) return abort();
|
|
143
|
+
timer = setTimeout(() => reject(new Error('Preview transport timed out')), 2000);
|
|
144
|
+
}),
|
|
145
|
+
]);
|
|
146
|
+
} finally { clearTimeout(timer); signal.removeEventListener('abort', abort); }
|
|
147
|
+
}
|
|
148
|
+
cancel(job) {
|
|
149
|
+
if (terminal(job.state)) return;
|
|
150
|
+
job.controller.abort(new DOMException('Scan cancelled', 'AbortError'));
|
|
151
|
+
if (job.state === 'queued' && !this.active.has(job)) {
|
|
152
|
+
this.queue = this.queue.filter(entry => entry !== job);
|
|
153
|
+
this.emit(job, { type: 'state', state: 'cancelled' });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async sweep() {
|
|
157
|
+
for (const job of this.jobs.values()) if (Date.now() >= job.expiresAt) {
|
|
158
|
+
this.cancel(job);
|
|
159
|
+
if (!terminal(job.state)) continue;
|
|
160
|
+
this.jobs.delete(job.id);
|
|
161
|
+
for (const listener of job.listeners) listener('', true);
|
|
162
|
+
await rm(job.output, { recursive: true, force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
async close() {
|
|
166
|
+
this.closed = true; clearInterval(this.sweeper);
|
|
167
|
+
for (const job of this.jobs.values()) this.cancel(job);
|
|
168
|
+
await Promise.allSettled([...this.active].map(job => job.done));
|
|
169
|
+
for (const job of this.jobs.values()) for (const listener of job.listeners) listener('', true);
|
|
170
|
+
this.jobs.clear(); this.queue = [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { pngDimensions, MAX_PNG_BYTES } from '../scanner/png-snapshot.mjs';
|
|
3
|
+
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
|
4
|
+
|
|
5
|
+
/** Matched-region evidence only; correspondence comes exclusively from Shivax. */
|
|
6
|
+
export async function writePNGHighlights(pair, analysis, staging, signal) {
|
|
7
|
+
const regions = analysis.matching.matches.flatMap((match, index) => {
|
|
8
|
+
const baseline = pair.baseline.snapshot.nodes.find(n => n.id === match.referenceId)?.bounds;
|
|
9
|
+
const target = pair.target.snapshot.nodes.find(n => n.id === match.targetId)?.bounds;
|
|
10
|
+
if (baseline?.status !== 'known' || target?.status !== 'known') return [];
|
|
11
|
+
return [{ id: `match-${index + 1}`, baselineNodeId: match.referenceId, targetNodeId: match.targetId,
|
|
12
|
+
baseline: baseline.value, target: target.value, confidence: match.confidence, status: 'needs_review' }];
|
|
13
|
+
});
|
|
14
|
+
for (const side of ['baseline', 'target']) {
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
const image = await readFile(`${staging}/${side}.png`, { signal });
|
|
17
|
+
if (image.length > MAX_PNG_BYTES) throw new RangeError('Highlight screenshot exceeds byte limit');
|
|
18
|
+
const dimensions = pngDimensions(image);
|
|
19
|
+
const metadata = side === 'baseline' ? pair.baseline.metadata.image : null;
|
|
20
|
+
const scale = side === 'baseline' ? metadata.viewport.devicePixelRatio : pair.target.snapshot.viewport.devicePixelRatio;
|
|
21
|
+
const offset = side === 'baseline' && !metadata.fullPage ? metadata.scroll : { x: 0, y: 0 };
|
|
22
|
+
const boxes = regions.map(region => {
|
|
23
|
+
const box = region[side], x = (box.x - offset.x) * scale, y = (box.y - offset.y) * scale;
|
|
24
|
+
return `<g><title>${escape(`${region.id}: ${region.baselineNodeId} ↔ ${region.targetNodeId}; needs_review`)}</title><rect x="${x}" y="${y}" width="${box.width * scale}" height="${box.height * scale}" fill="none" stroke="#d946ef" stroke-width="2"/><text x="${Math.max(0, x)}" y="${Math.max(12, y + 12)}" fill="#111827" stroke="white" stroke-width="3" paint-order="stroke" font-size="12">${escape(region.id)}</text></g>`;
|
|
25
|
+
}).join('');
|
|
26
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${dimensions.width}" height="${dimensions.height}" viewBox="0 0 ${dimensions.width} ${dimensions.height}"><title>Matched regions — needs_review</title><image width="${dimensions.width}" height="${dimensions.height}" href="data:image/png;base64,${image.toString('base64')}"/>${boxes}</svg>`;
|
|
27
|
+
await writeFile(`${staging}/${side}.matches.svg`, svg, { mode: 0o600, signal });
|
|
28
|
+
}
|
|
29
|
+
return regions;
|
|
30
|
+
}
|