@anionex/dsh-vision-toolkit 0.1.5
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.i18n.yaml +6 -0
- package/README.md +383 -0
- package/README.zh.md +383 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/hero.png +0 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +16 -0
- package/assets/upstream/image-qa.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/screenshot-debugging.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-settings.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +952 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +117 -0
- package/lib/config.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/exposure.js +213 -0
- package/lib/exposure.js.map +1 -0
- package/lib/index.js +97 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +199 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +325 -0
- package/lib/paths.js.map +1 -0
- package/lib/runtime-install.js +601 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +126 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1344 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +139 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +528 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +179 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +57 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +73 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +40 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +21 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +107 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +49 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +389 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +15 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +22 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +207 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +4 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +74 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +675 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +20 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +244 -0
- package/lib/web.js.map +1 -0
- package/package.json +139 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/index.tsx +866 -0
- package/src/client/paste-images.tsx +426 -0
- package/src/config.ts +177 -0
- package/src/errors.ts +62 -0
- package/src/exposure.ts +227 -0
- package/src/index.ts +122 -0
- package/src/paste-images.ts +234 -0
- package/src/paths.ts +348 -0
- package/src/runtime-install.ts +723 -0
- package/src/runtime-manager.ts +166 -0
- package/src/runtime.ts +1783 -0
- package/src/skill.ts +143 -0
- package/src/tools.ts +668 -0
- package/src/upstream.ts +861 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +17 -0
- package/src/web.ts +329 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +399 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +56 -0
- package/vendor/agent-vision-toolkit/ground.py +216 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/vision_client.py +156 -0
package/lib/runtime.js
ADDED
|
@@ -0,0 +1,1344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vision Toolkit runtime: structured requests in, structured results out.
|
|
3
|
+
* One operation-wide deadline reaches every subprocess; image decoding,
|
|
4
|
+
* byte/pixel limits, session-scoped concurrency, credential resolution, safe
|
|
5
|
+
* output staging, and diagnostic logging stay below the model-facing tools.
|
|
6
|
+
* @module dsh-vision-toolkit/runtime
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
+
import { readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { basename, extname, join } from 'node:path';
|
|
11
|
+
import { SaxesParser } from 'saxes';
|
|
12
|
+
import { describeArtifact } from "./artifacts.js";
|
|
13
|
+
import { VisionToolkitError } from "./errors.js";
|
|
14
|
+
import { assertDistinctOutput, commitStagedDirectory, commitStagedOutput, createPathPolicy, createStagedDirectory, createStagedOutput, isWithin, resolveHtmlFile, resolveInputFile, resolveOutputDirectory, resolveOutputFile, seedStagedDirectory, } from "./paths.js";
|
|
15
|
+
import { parseCropOutput, parseDominantColorsOutput, parseExtractForegroundOutput, parseHtmlScreenshotOutput, parseLocationOutput, parsePixelDiffOutput, parseTraceOutput, UpstreamAdapter, } from "./upstream.js";
|
|
16
|
+
import { PLUGIN_VERSION } from "./version.js";
|
|
17
|
+
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
18
|
+
function svgDocumentPathCount(svg) {
|
|
19
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
20
|
+
let depth = 0;
|
|
21
|
+
let invalid = false;
|
|
22
|
+
let pathCount = 0;
|
|
23
|
+
let rootSeen = false;
|
|
24
|
+
let rootClosed = false;
|
|
25
|
+
parser.on('doctype', () => { invalid = true; });
|
|
26
|
+
parser.on('error', () => { invalid = true; });
|
|
27
|
+
parser.on('opentag', (tag) => {
|
|
28
|
+
if (depth === 0) {
|
|
29
|
+
if (rootSeen || tag.local !== 'svg' || tag.uri !== SVG_NAMESPACE)
|
|
30
|
+
invalid = true;
|
|
31
|
+
rootSeen = true;
|
|
32
|
+
}
|
|
33
|
+
if (tag.local === 'path' && tag.uri === SVG_NAMESPACE)
|
|
34
|
+
pathCount += 1;
|
|
35
|
+
depth += 1;
|
|
36
|
+
});
|
|
37
|
+
parser.on('closetag', () => {
|
|
38
|
+
depth -= 1;
|
|
39
|
+
if (depth === 0)
|
|
40
|
+
rootClosed = true;
|
|
41
|
+
if (depth < 0)
|
|
42
|
+
invalid = true;
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
parser.write(svg).close();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return invalid || !rootSeen || !rootClosed || depth !== 0 ? undefined : pathCount;
|
|
51
|
+
}
|
|
52
|
+
/** Combine a caller abort signal with one hard operation timeout. */
|
|
53
|
+
export function createDeadline(signal, timeoutMs) {
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const state = { timedOut: false, cancelled: false };
|
|
56
|
+
const onCallerAbort = () => {
|
|
57
|
+
if (controller.signal.aborted)
|
|
58
|
+
return;
|
|
59
|
+
state.cancelled = true;
|
|
60
|
+
controller.abort();
|
|
61
|
+
};
|
|
62
|
+
if (signal.aborted) {
|
|
63
|
+
state.cancelled = true;
|
|
64
|
+
controller.abort();
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
signal.addEventListener('abort', onCallerAbort, { once: true });
|
|
68
|
+
}
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
if (controller.signal.aborted)
|
|
71
|
+
return;
|
|
72
|
+
state.timedOut = true;
|
|
73
|
+
controller.abort();
|
|
74
|
+
}, timeoutMs);
|
|
75
|
+
return {
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
get timedOut() { return state.timedOut; },
|
|
78
|
+
get cancelled() { return state.cancelled; },
|
|
79
|
+
cleanup() {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
signal.removeEventListener('abort', onCallerAbort);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** FIFO bounded concurrency gate whose queued callers remain cancellable. */
|
|
86
|
+
export class Semaphore {
|
|
87
|
+
limit;
|
|
88
|
+
active = 0;
|
|
89
|
+
waiters = [];
|
|
90
|
+
constructor(limit) {
|
|
91
|
+
this.limit = limit;
|
|
92
|
+
}
|
|
93
|
+
/** Whether no active or queued caller still owns this gate. */
|
|
94
|
+
get idle() {
|
|
95
|
+
return this.active === 0 && this.waiters.length === 0;
|
|
96
|
+
}
|
|
97
|
+
/** Acquire one slot, aborting while queued when `signal` fires. */
|
|
98
|
+
async acquire(signal, permits = 1) {
|
|
99
|
+
if (signal.aborted)
|
|
100
|
+
throw new VisionToolkitError('cancelled', 'vision-toolkit: cancelled before execution');
|
|
101
|
+
if (!Number.isInteger(permits) || permits < 1 || permits > this.limit) {
|
|
102
|
+
throw new VisionToolkitError('input', `concurrency permits must be between 1 and ${this.limit}`);
|
|
103
|
+
}
|
|
104
|
+
if (this.waiters.length === 0 && this.active + permits <= this.limit) {
|
|
105
|
+
this.active += permits;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
return new Promise((resolveAcquire, reject) => {
|
|
109
|
+
const entry = {
|
|
110
|
+
resolve: resolveAcquire,
|
|
111
|
+
reject,
|
|
112
|
+
signal,
|
|
113
|
+
permits,
|
|
114
|
+
onAbort: () => { },
|
|
115
|
+
};
|
|
116
|
+
entry.onAbort = () => {
|
|
117
|
+
const index = this.waiters.indexOf(entry);
|
|
118
|
+
if (index >= 0)
|
|
119
|
+
this.waiters.splice(index, 1);
|
|
120
|
+
reject(new VisionToolkitError('cancelled', 'vision-toolkit: cancelled while waiting for a concurrency slot'));
|
|
121
|
+
};
|
|
122
|
+
this.waiters.push(entry);
|
|
123
|
+
signal.addEventListener('abort', entry.onAbort, { once: true });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/** Release owned permits and wake FIFO waiters whose full weight now fits. */
|
|
127
|
+
release(permits = 1) {
|
|
128
|
+
this.active = Math.max(0, this.active - permits);
|
|
129
|
+
while (this.waiters.length > 0) {
|
|
130
|
+
const next = this.waiters[0];
|
|
131
|
+
if (next === undefined || this.active + next.permits > this.limit)
|
|
132
|
+
break;
|
|
133
|
+
this.waiters.shift();
|
|
134
|
+
next.signal.removeEventListener('abort', next.onAbort);
|
|
135
|
+
this.active += next.permits;
|
|
136
|
+
next.resolve();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const REGION_PATTERN = /^\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*$/;
|
|
141
|
+
const MAX_TIMEOUT_MS = 600_000;
|
|
142
|
+
const FORMAT_BY_EXTENSION = new Map([
|
|
143
|
+
['.png', 'png'],
|
|
144
|
+
['.jpg', 'jpeg'],
|
|
145
|
+
['.jpeg', 'jpeg'],
|
|
146
|
+
['.gif', 'gif'],
|
|
147
|
+
['.webp', 'webp'],
|
|
148
|
+
]);
|
|
149
|
+
const HEX_COLOR_PATTERN = /^#[0-9A-F]{6}$/;
|
|
150
|
+
function integerInRange(value, fallback, minimum, maximum, name) {
|
|
151
|
+
const resolved = value ?? fallback;
|
|
152
|
+
if (!Number.isInteger(resolved) || resolved < minimum || resolved > maximum) {
|
|
153
|
+
throw new VisionToolkitError('input', `${name} must be an integer between ${minimum} and ${maximum}`);
|
|
154
|
+
}
|
|
155
|
+
return resolved;
|
|
156
|
+
}
|
|
157
|
+
function finiteInRange(value, minimum, maximum, name) {
|
|
158
|
+
if (value === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
if (!Number.isFinite(value) || value < minimum || value > maximum) {
|
|
161
|
+
throw new VisionToolkitError('input', `${name} must be between ${minimum} and ${maximum}`);
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
function assertBoxWithin(box, width, height, source) {
|
|
166
|
+
if (![box.x1, box.y1, box.x2, box.y2].every(Number.isInteger)
|
|
167
|
+
|| box.x1 < 0
|
|
168
|
+
|| box.y1 < 0
|
|
169
|
+
|| box.x2 <= box.x1
|
|
170
|
+
|| box.y2 <= box.y1
|
|
171
|
+
|| box.x2 > width
|
|
172
|
+
|| box.y2 > height) {
|
|
173
|
+
throw new VisionToolkitError('output', `${source} returned an out-of-range box for ${width}x${height}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function safeGeneratedName(name, source) {
|
|
177
|
+
if (typeof name !== 'string' || name.length === 0 || basename(name) !== name || name === '.' || name === '..') {
|
|
178
|
+
throw new VisionToolkitError('output', `${source} returned an unsafe generated filename`);
|
|
179
|
+
}
|
|
180
|
+
return name;
|
|
181
|
+
}
|
|
182
|
+
function objectRecord(value, source) {
|
|
183
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
184
|
+
throw new VisionToolkitError('output', `${source} must be a JSON object`);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
function manifestInteger(record, key, source) {
|
|
189
|
+
const value = record[key];
|
|
190
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
191
|
+
throw new VisionToolkitError('output', `${source}.${key} must be an integer`);
|
|
192
|
+
}
|
|
193
|
+
return value;
|
|
194
|
+
}
|
|
195
|
+
function parseLongOcrManifest(text, expected) {
|
|
196
|
+
let parsed;
|
|
197
|
+
try {
|
|
198
|
+
parsed = JSON.parse(text);
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest is not valid JSON', { cause: error });
|
|
202
|
+
}
|
|
203
|
+
const manifest = objectRecord(parsed, 'long_screenshot_ocr manifest');
|
|
204
|
+
if (manifest.schema_version !== 1)
|
|
205
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: unsupported manifest schema');
|
|
206
|
+
if (manifest.input !== expected.source)
|
|
207
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest source does not match the requested image');
|
|
208
|
+
if (manifestInteger(manifest, 'image_width', 'long_screenshot_ocr manifest') !== expected.width
|
|
209
|
+
|| manifestInteger(manifest, 'image_height', 'long_screenshot_ocr manifest') !== expected.height) {
|
|
210
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest dimensions do not match the source image');
|
|
211
|
+
}
|
|
212
|
+
if (manifest.mode !== expected.mode)
|
|
213
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest mode does not match the request');
|
|
214
|
+
const complete = manifest.complete;
|
|
215
|
+
if (typeof complete !== 'boolean' || complete === expected.splitOnly) {
|
|
216
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest completion state is inconsistent');
|
|
217
|
+
}
|
|
218
|
+
if (!Array.isArray(manifest.chunks) || manifest.chunks.length === 0) {
|
|
219
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest contains no chunks');
|
|
220
|
+
}
|
|
221
|
+
if (manifest.output !== (expected.splitOnly ? null : expected.output)) {
|
|
222
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest output path is inconsistent');
|
|
223
|
+
}
|
|
224
|
+
const chunks = manifest.chunks.map((value, position) => {
|
|
225
|
+
const record = objectRecord(value, `long_screenshot_ocr manifest.chunks[${position}]`);
|
|
226
|
+
const index = manifestInteger(record, 'index', `long_screenshot_ocr manifest.chunks[${position}]`);
|
|
227
|
+
if (index !== position + 1)
|
|
228
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: chunk indexes are not contiguous');
|
|
229
|
+
const image = safeGeneratedName(record.image, 'long_screenshot_ocr');
|
|
230
|
+
const imageSha256 = record.image_sha256;
|
|
231
|
+
if (typeof imageSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(imageSha256)) {
|
|
232
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: chunk image hash is invalid');
|
|
233
|
+
}
|
|
234
|
+
const coreTop = manifestInteger(record, 'core_top', `long_screenshot_ocr manifest.chunks[${position}]`);
|
|
235
|
+
const coreBottom = manifestInteger(record, 'core_bottom', `long_screenshot_ocr manifest.chunks[${position}]`);
|
|
236
|
+
const cropTop = manifestInteger(record, 'crop_top', `long_screenshot_ocr manifest.chunks[${position}]`);
|
|
237
|
+
const cropBottom = manifestInteger(record, 'crop_bottom', `long_screenshot_ocr manifest.chunks[${position}]`);
|
|
238
|
+
if (coreTop < 0
|
|
239
|
+
|| coreBottom <= coreTop
|
|
240
|
+
|| cropTop < 0
|
|
241
|
+
|| cropBottom <= cropTop
|
|
242
|
+
|| cropTop > coreTop
|
|
243
|
+
|| cropBottom < coreBottom
|
|
244
|
+
|| cropBottom > expected.height) {
|
|
245
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: manifest contains an invalid chunk range');
|
|
246
|
+
}
|
|
247
|
+
const ocr = record.ocr === undefined ? undefined : safeGeneratedName(record.ocr, 'long_screenshot_ocr');
|
|
248
|
+
const ocrReused = record.ocr_reused;
|
|
249
|
+
if (ocrReused !== undefined && typeof ocrReused !== 'boolean') {
|
|
250
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: ocr_reused must be boolean');
|
|
251
|
+
}
|
|
252
|
+
if (complete && ocr === undefined)
|
|
253
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: complete manifest is missing an OCR sidecar');
|
|
254
|
+
return {
|
|
255
|
+
index,
|
|
256
|
+
image,
|
|
257
|
+
imageSha256,
|
|
258
|
+
coreTop,
|
|
259
|
+
coreBottom,
|
|
260
|
+
cropTop,
|
|
261
|
+
cropBottom,
|
|
262
|
+
...(ocr === undefined ? {} : { ocr }),
|
|
263
|
+
...(ocrReused === undefined ? {} : { ocrReused }),
|
|
264
|
+
};
|
|
265
|
+
});
|
|
266
|
+
return { mode: expected.mode, complete, chunks, raw: manifest };
|
|
267
|
+
}
|
|
268
|
+
/** Parse a non-empty four-integer pixel box. */
|
|
269
|
+
export function parseRegion(region) {
|
|
270
|
+
const match = REGION_PATTERN.exec(region);
|
|
271
|
+
if (match === null) {
|
|
272
|
+
throw new VisionToolkitError('input', 'region must be four integers: X1,Y1,X2,Y2 (pixels)');
|
|
273
|
+
}
|
|
274
|
+
const box = {
|
|
275
|
+
x1: Number(match[1]),
|
|
276
|
+
y1: Number(match[2]),
|
|
277
|
+
x2: Number(match[3]),
|
|
278
|
+
y2: Number(match[4]),
|
|
279
|
+
};
|
|
280
|
+
if (box.x2 <= box.x1 || box.y2 <= box.y1) {
|
|
281
|
+
throw new VisionToolkitError('input', 'region must have x2 > x1 and y2 > y1');
|
|
282
|
+
}
|
|
283
|
+
return box;
|
|
284
|
+
}
|
|
285
|
+
/** Runtime facade used by every native tool. */
|
|
286
|
+
export class VisionToolkitRuntime {
|
|
287
|
+
ctx;
|
|
288
|
+
config;
|
|
289
|
+
semaphores = new Map();
|
|
290
|
+
glanceCache = new WeakMap();
|
|
291
|
+
adapter;
|
|
292
|
+
constructor(ctx, config, adapter) {
|
|
293
|
+
this.ctx = ctx;
|
|
294
|
+
this.config = config;
|
|
295
|
+
this.adapter = adapter ?? new UpstreamAdapter(ctx, config);
|
|
296
|
+
}
|
|
297
|
+
/** Pinned and prepared upstream identity. */
|
|
298
|
+
get upstreamVersion() {
|
|
299
|
+
return this.adapter.versionInfo;
|
|
300
|
+
}
|
|
301
|
+
timeout(options) {
|
|
302
|
+
const value = options.timeoutMs ?? this.config.timeoutMs;
|
|
303
|
+
if (!Number.isInteger(value) || value < 1000 || value > MAX_TIMEOUT_MS) {
|
|
304
|
+
throw new VisionToolkitError('input', `timeoutMs must be an integer between 1000 and ${MAX_TIMEOUT_MS}`);
|
|
305
|
+
}
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
operationError(tool, error, deadline) {
|
|
309
|
+
if (deadline.cancelled)
|
|
310
|
+
return new VisionToolkitError('cancelled', `${tool}: cancelled`);
|
|
311
|
+
if (deadline.timedOut)
|
|
312
|
+
return new VisionToolkitError('timeout', `${tool}: timed out`);
|
|
313
|
+
if (error instanceof VisionToolkitError)
|
|
314
|
+
return error;
|
|
315
|
+
return new VisionToolkitError('runtime', `${tool}: execution failed`, { cause: error });
|
|
316
|
+
}
|
|
317
|
+
semaphore(options) {
|
|
318
|
+
const key = options.sessionId ?? `workspace:${options.workspace}`;
|
|
319
|
+
const value = this.semaphores.get(key) ?? new Semaphore(this.config.concurrency);
|
|
320
|
+
this.semaphores.set(key, value);
|
|
321
|
+
return { key, value };
|
|
322
|
+
}
|
|
323
|
+
async runOperation(tool, options, action, permits = 1) {
|
|
324
|
+
const deadline = createDeadline(options.signal, this.timeout(options));
|
|
325
|
+
const semaphore = this.semaphore(options);
|
|
326
|
+
const metrics = {
|
|
327
|
+
startedAt: Date.now(),
|
|
328
|
+
upstreamMs: 0,
|
|
329
|
+
imageBytes: 0,
|
|
330
|
+
imagePixels: 0,
|
|
331
|
+
imageCount: 0,
|
|
332
|
+
cacheHits: 0,
|
|
333
|
+
usedVisionService: false,
|
|
334
|
+
};
|
|
335
|
+
let acquired = false;
|
|
336
|
+
try {
|
|
337
|
+
await semaphore.value.acquire(deadline.signal, permits);
|
|
338
|
+
acquired = true;
|
|
339
|
+
const value = await action({ signal: deadline.signal, metrics });
|
|
340
|
+
if (deadline.signal.aborted)
|
|
341
|
+
throw this.operationError(tool, undefined, deadline);
|
|
342
|
+
this.ctx.logger.info('dsh-vision-toolkit tool=%s outcome=ok totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d model=%s', tool, Date.now() - metrics.startedAt, metrics.upstreamMs, metrics.imageCount, metrics.imageBytes, metrics.imagePixels, metrics.cacheHits, metrics.usedVisionService ? this.config.provider.model : 'local');
|
|
343
|
+
return value;
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
const classified = this.operationError(tool, error, deadline);
|
|
347
|
+
this.ctx.logger.warn('dsh-vision-toolkit tool=%s outcome=error category=%s totalMs=%d upstreamMs=%d images=%d imageBytes=%d imagePixels=%d cacheHits=%d', tool, classified.code, Date.now() - metrics.startedAt, metrics.upstreamMs, metrics.imageCount, metrics.imageBytes, metrics.imagePixels, metrics.cacheHits);
|
|
348
|
+
throw classified;
|
|
349
|
+
}
|
|
350
|
+
finally {
|
|
351
|
+
if (acquired)
|
|
352
|
+
semaphore.value.release(permits);
|
|
353
|
+
deadline.cleanup();
|
|
354
|
+
if (semaphore.value.idle)
|
|
355
|
+
this.semaphores.delete(semaphore.key);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/** Resolve the configured credential at the remote-operation boundary. */
|
|
359
|
+
async resolveVisionEnv() {
|
|
360
|
+
const resolved = await this.ctx.credentials.resolve(this.config.provider.credential);
|
|
361
|
+
if (resolved === undefined) {
|
|
362
|
+
throw new VisionToolkitError('config', `credential ${this.config.provider.credential} is not configured; set it through DSH credentials`);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
VISION_API_KEY: resolved.value,
|
|
366
|
+
VISION_BASE_URL: this.config.provider.baseUrl,
|
|
367
|
+
VISION_MODEL: this.config.provider.model,
|
|
368
|
+
LANG: this.config.language,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
pathPolicy(workspace) {
|
|
372
|
+
return createPathPolicy(workspace, this.config.allowedDirs);
|
|
373
|
+
}
|
|
374
|
+
async validateImage(raw, policy, operation) {
|
|
375
|
+
const image = await resolveInputFile(raw, policy);
|
|
376
|
+
if (image.bytes > this.config.maxImageBytes) {
|
|
377
|
+
throw new VisionToolkitError('capacity', `image is ${image.bytes} bytes, exceeding maxImageBytes ${this.config.maxImageBytes}`);
|
|
378
|
+
}
|
|
379
|
+
const decoded = await this.adapter.probeImageSize(image.path, { signal: operation.signal });
|
|
380
|
+
const pixels = decoded.width * decoded.height;
|
|
381
|
+
if (!Number.isSafeInteger(pixels) || pixels > this.config.maxImagePixels) {
|
|
382
|
+
throw new VisionToolkitError('capacity', `image is ${decoded.width}x${decoded.height} (${pixels} pixels), exceeding maxImagePixels ${this.config.maxImagePixels}`);
|
|
383
|
+
}
|
|
384
|
+
const extension = extname(image.path).toLowerCase();
|
|
385
|
+
const expected = FORMAT_BY_EXTENSION.get(extension);
|
|
386
|
+
if (expected !== decoded.format) {
|
|
387
|
+
throw new VisionToolkitError('input', `image content is ${decoded.format}, but the filename uses ${extension}`);
|
|
388
|
+
}
|
|
389
|
+
return { ...image, width: decoded.width, height: decoded.height, format: decoded.format };
|
|
390
|
+
}
|
|
391
|
+
accountImage(image, operation) {
|
|
392
|
+
operation.metrics.imageCount += 1;
|
|
393
|
+
operation.metrics.imageBytes += image.bytes;
|
|
394
|
+
operation.metrics.imagePixels += image.width * image.height;
|
|
395
|
+
}
|
|
396
|
+
async glanceCacheKey(request, images, env, signal) {
|
|
397
|
+
const imageFingerprints = await Promise.all(images.map(async (image) => {
|
|
398
|
+
let bytes;
|
|
399
|
+
try {
|
|
400
|
+
bytes = await readFile(image.path, { signal });
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
throw new VisionToolkitError('input', `image changed while preparing the vision request: ${image.path}`, { cause: error });
|
|
404
|
+
}
|
|
405
|
+
if (bytes.length !== image.bytes) {
|
|
406
|
+
throw new VisionToolkitError('input', `image changed while preparing the vision request: ${image.path}`);
|
|
407
|
+
}
|
|
408
|
+
return {
|
|
409
|
+
path: image.path,
|
|
410
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
411
|
+
};
|
|
412
|
+
}));
|
|
413
|
+
return JSON.stringify({
|
|
414
|
+
images: imageFingerprints,
|
|
415
|
+
query: request.query ?? null,
|
|
416
|
+
ocr: request.ocr === true,
|
|
417
|
+
region: request.region ?? null,
|
|
418
|
+
provider: {
|
|
419
|
+
baseUrl: env.VISION_BASE_URL,
|
|
420
|
+
model: env.VISION_MODEL,
|
|
421
|
+
language: env.LANG,
|
|
422
|
+
credentialSha256: createHash('sha256').update(env.VISION_API_KEY).digest('hex'),
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
async runUpstream(tool, args, operation, env) {
|
|
427
|
+
const started = Date.now();
|
|
428
|
+
if (env !== undefined)
|
|
429
|
+
operation.metrics.usedVisionService = true;
|
|
430
|
+
const result = await this.adapter.run(tool, args, {
|
|
431
|
+
signal: operation.signal,
|
|
432
|
+
...(env === undefined ? {} : { env }),
|
|
433
|
+
});
|
|
434
|
+
operation.metrics.upstreamMs += Date.now() - started;
|
|
435
|
+
if (result.outcome.exitCode !== 0) {
|
|
436
|
+
throw this.adapter.classifyFailure(tool, result, {
|
|
437
|
+
timedOut: false,
|
|
438
|
+
cancelled: operation.signal.aborted,
|
|
439
|
+
...(env === undefined ? {} : { secrets: [env.VISION_API_KEY] }),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
if (result.stdoutTruncated || result.stderrTruncated) {
|
|
443
|
+
throw new VisionToolkitError('output', `${tool}: upstream output exceeded the capture limit`);
|
|
444
|
+
}
|
|
445
|
+
return result;
|
|
446
|
+
}
|
|
447
|
+
async probeGeneratedImage(path, operation, source) {
|
|
448
|
+
try {
|
|
449
|
+
return await this.adapter.probeImageSize(path, { signal: operation.signal });
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
if (operation.signal.aborted)
|
|
453
|
+
throw error;
|
|
454
|
+
throw new VisionToolkitError('output', `${source}: generated image is missing, corrupt, or unsupported`, { cause: error });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
async annotateLocations(tool, image, elements, output, policy, operation) {
|
|
458
|
+
const extension = extname(image.path).toLowerCase();
|
|
459
|
+
const stem = basename(image.path, extension);
|
|
460
|
+
const suffix = tool === 'vision_ground' ? 'ground' : 'detect';
|
|
461
|
+
const finalPath = resolveOutputFile(output, policy, `${stem}.${suffix}.preview.png`, ['.png']);
|
|
462
|
+
assertDistinctOutput(image.path, finalPath);
|
|
463
|
+
const staged = createStagedOutput(policy, '.png');
|
|
464
|
+
try {
|
|
465
|
+
const started = Date.now();
|
|
466
|
+
await this.adapter.renderAnnotatedPreview(image.path, staged, elements, { signal: operation.signal });
|
|
467
|
+
operation.metrics.upstreamMs += Date.now() - started;
|
|
468
|
+
const preview = await this.probeGeneratedImage(staged, operation, tool);
|
|
469
|
+
if (preview.format !== 'png' || preview.width !== image.width || preview.height !== image.height) {
|
|
470
|
+
throw new VisionToolkitError('output', `${tool}: annotation preview dimensions or format do not match the source image`);
|
|
471
|
+
}
|
|
472
|
+
await commitStagedOutput(staged, finalPath, policy);
|
|
473
|
+
return describeArtifact(finalPath, policy, {
|
|
474
|
+
mimeType: 'image/png',
|
|
475
|
+
kind: 'image',
|
|
476
|
+
description: tool === 'vision_ground' ? 'Grounding bounding-box preview' : 'Detected-element bounding-box preview',
|
|
477
|
+
sourceTool: tool,
|
|
478
|
+
previewIntent: 'image',
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
finally {
|
|
482
|
+
await rm(staged, { force: true }).catch(() => { });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** glance: describe, targeted QA, OCR, or multi-image comparison. */
|
|
486
|
+
async glance(request, options) {
|
|
487
|
+
return this.runOperation('vision_glance', options, async (operation) => {
|
|
488
|
+
if (request.images.length === 0)
|
|
489
|
+
throw new VisionToolkitError('input', 'glance requires at least one image');
|
|
490
|
+
if (request.query !== undefined && request.ocr === true) {
|
|
491
|
+
throw new VisionToolkitError('input', 'glance: query and ocr are mutually exclusive');
|
|
492
|
+
}
|
|
493
|
+
if (request.region !== undefined && request.images.length > 1) {
|
|
494
|
+
throw new VisionToolkitError('input', 'glance: region works with exactly one image');
|
|
495
|
+
}
|
|
496
|
+
if (request.region !== undefined)
|
|
497
|
+
parseRegion(request.region);
|
|
498
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
499
|
+
const images = [];
|
|
500
|
+
const seen = new Set();
|
|
501
|
+
for (const raw of request.images) {
|
|
502
|
+
const image = await this.validateImage(raw, policy, operation);
|
|
503
|
+
if (seen.has(image.path)) {
|
|
504
|
+
operation.metrics.cacheHits += 1;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
seen.add(image.path);
|
|
508
|
+
this.accountImage(image, operation);
|
|
509
|
+
images.push(image);
|
|
510
|
+
}
|
|
511
|
+
const env = await this.resolveVisionEnv();
|
|
512
|
+
const cacheKey = options.sessionScope === undefined
|
|
513
|
+
? undefined
|
|
514
|
+
: await this.glanceCacheKey(request, images, env, operation.signal);
|
|
515
|
+
if (options.sessionScope !== undefined && cacheKey !== undefined) {
|
|
516
|
+
const cached = this.glanceCache.get(options.sessionScope);
|
|
517
|
+
if (cached?.key === cacheKey) {
|
|
518
|
+
operation.metrics.cacheHits += 1;
|
|
519
|
+
return cached.result;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
const result = await this.runUpstream('glance', [
|
|
523
|
+
...images.map(image => image.path),
|
|
524
|
+
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
525
|
+
...(request.ocr === true ? ['--ocr'] : []),
|
|
526
|
+
...(request.query !== undefined ? ['-q', request.query] : []),
|
|
527
|
+
], operation, env);
|
|
528
|
+
const answer = result.stdout.trim();
|
|
529
|
+
if (answer.length === 0)
|
|
530
|
+
throw new VisionToolkitError('output', 'glance: vision API returned an empty description');
|
|
531
|
+
const value = {
|
|
532
|
+
images,
|
|
533
|
+
mode: request.ocr === true ? 'ocr' : request.query !== undefined ? 'qa' : 'describe',
|
|
534
|
+
answer,
|
|
535
|
+
truncated: false,
|
|
536
|
+
};
|
|
537
|
+
if (options.sessionScope !== undefined && cacheKey !== undefined && !operation.signal.aborted) {
|
|
538
|
+
this.glanceCache.set(options.sessionScope, { key: cacheKey, result: value });
|
|
539
|
+
}
|
|
540
|
+
return value;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
validateLocations(elements, width, height) {
|
|
544
|
+
for (const element of elements) {
|
|
545
|
+
const { x1, y1, x2, y2 } = element.box;
|
|
546
|
+
if (![x1, y1, x2, y2].every(Number.isInteger)
|
|
547
|
+
|| x1 < 0
|
|
548
|
+
|| y1 < 0
|
|
549
|
+
|| x2 <= x1
|
|
550
|
+
|| y2 <= y1
|
|
551
|
+
|| x2 > width
|
|
552
|
+
|| y2 > height) {
|
|
553
|
+
throw new VisionToolkitError('output', `upstream returned an out-of-range box for ${width}x${height}`);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
async locate(request, options, operation, tool) {
|
|
558
|
+
if (request.target.trim().length === 0)
|
|
559
|
+
throw new VisionToolkitError('input', 'target must not be empty');
|
|
560
|
+
if (request.region !== undefined)
|
|
561
|
+
parseRegion(request.region);
|
|
562
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
563
|
+
const image = await this.validateImage(request.image, policy, operation);
|
|
564
|
+
this.accountImage(image, operation);
|
|
565
|
+
const env = await this.resolveVisionEnv();
|
|
566
|
+
const result = await this.runUpstream(tool, [
|
|
567
|
+
image.path,
|
|
568
|
+
request.target,
|
|
569
|
+
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
570
|
+
], operation, env);
|
|
571
|
+
const elements = parseLocationOutput(result.stdout);
|
|
572
|
+
this.validateLocations(elements, image.width, image.height);
|
|
573
|
+
return { image, elements };
|
|
574
|
+
}
|
|
575
|
+
/** ground: locate one named target and return pixel boxes. */
|
|
576
|
+
async ground(request, options) {
|
|
577
|
+
return this.runOperation('vision_ground', options, async (operation) => {
|
|
578
|
+
const { image, elements } = await this.locate(request, options, operation, 'ground');
|
|
579
|
+
const labeled = elements.map(element => ({ label: element.label ?? request.target, box: element.box }));
|
|
580
|
+
const preview = request.preview === true
|
|
581
|
+
? await this.annotateLocations('vision_ground', image, labeled, request.previewOutput, await this.pathPolicy(options.workspace), operation)
|
|
582
|
+
: undefined;
|
|
583
|
+
return {
|
|
584
|
+
target: request.target,
|
|
585
|
+
image,
|
|
586
|
+
imageWidth: image.width,
|
|
587
|
+
imageHeight: image.height,
|
|
588
|
+
matches: labeled,
|
|
589
|
+
...(preview === undefined ? {} : { preview }),
|
|
590
|
+
};
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
/** detect: inventory every instance of a kind. */
|
|
594
|
+
async detect(request, options) {
|
|
595
|
+
return this.runOperation('vision_detect', options, async (operation) => {
|
|
596
|
+
const { image, elements } = await this.locate(request, options, operation, 'detect');
|
|
597
|
+
const labeled = elements.map(element => ({ label: element.label ?? request.target, box: element.box }));
|
|
598
|
+
const preview = request.preview === true
|
|
599
|
+
? await this.annotateLocations('vision_detect', image, labeled, request.previewOutput, await this.pathPolicy(options.workspace), operation)
|
|
600
|
+
: undefined;
|
|
601
|
+
return {
|
|
602
|
+
category: request.target,
|
|
603
|
+
image,
|
|
604
|
+
imageWidth: image.width,
|
|
605
|
+
imageHeight: image.height,
|
|
606
|
+
elements: labeled.map((element, index) => ({
|
|
607
|
+
index: index + 1,
|
|
608
|
+
label: element.label,
|
|
609
|
+
box: element.box,
|
|
610
|
+
})),
|
|
611
|
+
...(preview === undefined ? {} : { preview }),
|
|
612
|
+
};
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
/** crop: cut a pixel box into its own image file without requiring a credential. */
|
|
616
|
+
async crop(request, options) {
|
|
617
|
+
return this.runOperation('vision_crop', options, async (operation) => {
|
|
618
|
+
const region = parseRegion(request.region);
|
|
619
|
+
if (request.scale !== undefined && (!Number.isInteger(request.scale) || request.scale < 1 || request.scale > 8)) {
|
|
620
|
+
throw new VisionToolkitError('input', 'crop: scale must be an integer between 1 and 8');
|
|
621
|
+
}
|
|
622
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
623
|
+
const image = await this.validateImage(request.image, policy, operation);
|
|
624
|
+
this.accountImage(image, operation);
|
|
625
|
+
const sourceExtension = extname(image.path).toLowerCase();
|
|
626
|
+
const stem = basename(image.path, sourceExtension);
|
|
627
|
+
const finalPath = resolveOutputFile(request.output, policy, request.scale !== undefined && request.scale > 1 ? `${stem}.crop@${request.scale}x.png` : `${stem}.crop.png`, ['.png', '.jpg', '.jpeg']);
|
|
628
|
+
assertDistinctOutput(image.path, finalPath);
|
|
629
|
+
const outputExtension = extname(finalPath).toLowerCase();
|
|
630
|
+
const staged = createStagedOutput(policy, outputExtension);
|
|
631
|
+
try {
|
|
632
|
+
const result = await this.runUpstream('crop', [
|
|
633
|
+
image.path,
|
|
634
|
+
'--region',
|
|
635
|
+
request.region,
|
|
636
|
+
'-o',
|
|
637
|
+
staged,
|
|
638
|
+
...(request.scale !== undefined ? ['--scale', String(request.scale)] : []),
|
|
639
|
+
], operation);
|
|
640
|
+
const parsed = parseCropOutput(result.stdout, result.stderr);
|
|
641
|
+
const generated = await this.probeGeneratedImage(staged, operation, 'crop');
|
|
642
|
+
const expectedFormat = outputExtension === '.png' ? 'png' : 'jpeg';
|
|
643
|
+
if (generated.format !== expectedFormat
|
|
644
|
+
|| generated.width !== parsed.width
|
|
645
|
+
|| generated.height !== parsed.height) {
|
|
646
|
+
throw new VisionToolkitError('output', 'crop: generated image does not match the upstream summary');
|
|
647
|
+
}
|
|
648
|
+
await commitStagedOutput(staged, finalPath, policy);
|
|
649
|
+
const mimeType = outputExtension === '.png' ? 'image/png' : 'image/jpeg';
|
|
650
|
+
const artifact = await describeArtifact(finalPath, policy, {
|
|
651
|
+
mimeType,
|
|
652
|
+
kind: 'image',
|
|
653
|
+
description: 'Cropped image region',
|
|
654
|
+
sourceTool: 'vision_crop',
|
|
655
|
+
previewIntent: 'image',
|
|
656
|
+
});
|
|
657
|
+
return {
|
|
658
|
+
imageWidth: image.width,
|
|
659
|
+
imageHeight: image.height,
|
|
660
|
+
region,
|
|
661
|
+
outputPath: finalPath,
|
|
662
|
+
mimeType,
|
|
663
|
+
width: parsed.width,
|
|
664
|
+
height: parsed.height,
|
|
665
|
+
clamped: parsed.clamped,
|
|
666
|
+
artifact,
|
|
667
|
+
...(parsed.note === undefined ? {} : { note: parsed.note }),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
finally {
|
|
671
|
+
await rm(staged, { force: true }).catch(() => { });
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/** trace: recover an SVG through the pinned upstream vtracer pipeline. */
|
|
676
|
+
async trace(request, options) {
|
|
677
|
+
return this.runOperation('vision_trace', options, async (operation) => {
|
|
678
|
+
if (request.region !== undefined)
|
|
679
|
+
parseRegion(request.region);
|
|
680
|
+
if (request.scale !== undefined && (!Number.isInteger(request.scale) || request.scale < 1 || request.scale > 16)) {
|
|
681
|
+
throw new VisionToolkitError('input', 'trace: scale must be an integer between 1 and 16');
|
|
682
|
+
}
|
|
683
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
684
|
+
const image = await this.validateImage(request.image, policy, operation);
|
|
685
|
+
this.accountImage(image, operation);
|
|
686
|
+
const extension = extname(image.path).toLowerCase();
|
|
687
|
+
const stem = basename(image.path, extension);
|
|
688
|
+
const finalPath = resolveOutputFile(request.output, policy, `${stem}.svg`, ['.svg']);
|
|
689
|
+
assertDistinctOutput(image.path, finalPath);
|
|
690
|
+
const staged = createStagedOutput(policy, '.svg');
|
|
691
|
+
try {
|
|
692
|
+
const result = await this.runUpstream('trace', [
|
|
693
|
+
image.path,
|
|
694
|
+
...(request.region !== undefined ? ['--region', request.region] : []),
|
|
695
|
+
...(request.scale !== undefined ? ['--scale', String(request.scale)] : []),
|
|
696
|
+
...(request.polygon === true ? ['--polygon'] : []),
|
|
697
|
+
...(request.color === true ? ['--color'] : []),
|
|
698
|
+
'-o',
|
|
699
|
+
staged,
|
|
700
|
+
], operation);
|
|
701
|
+
const parsed = parseTraceOutput(result.stdout);
|
|
702
|
+
const svg = await readFile(staged, 'utf8').catch(() => '');
|
|
703
|
+
const actualPathCount = svgDocumentPathCount(svg);
|
|
704
|
+
if (actualPathCount === undefined) {
|
|
705
|
+
throw new VisionToolkitError('output', 'trace: output SVG is not a parseable document');
|
|
706
|
+
}
|
|
707
|
+
if (actualPathCount !== parsed.pathCount) {
|
|
708
|
+
throw new VisionToolkitError('output', 'trace: reported path count does not match the generated SVG');
|
|
709
|
+
}
|
|
710
|
+
const svgInfo = await stat(staged);
|
|
711
|
+
if (svgInfo.size !== parsed.bytes) {
|
|
712
|
+
throw new VisionToolkitError('output', 'trace: reported byte count does not match the generated SVG');
|
|
713
|
+
}
|
|
714
|
+
await commitStagedOutput(staged, finalPath, policy);
|
|
715
|
+
const artifact = await describeArtifact(finalPath, policy, {
|
|
716
|
+
mimeType: 'image/svg+xml',
|
|
717
|
+
kind: 'svg',
|
|
718
|
+
description: 'Traced vector geometry',
|
|
719
|
+
sourceTool: 'vision_trace',
|
|
720
|
+
previewIntent: 'svg',
|
|
721
|
+
});
|
|
722
|
+
const warning = result.stderr.trim();
|
|
723
|
+
return {
|
|
724
|
+
imageWidth: image.width,
|
|
725
|
+
imageHeight: image.height,
|
|
726
|
+
outputPath: finalPath,
|
|
727
|
+
mimeType: 'image/svg+xml',
|
|
728
|
+
geometry: {
|
|
729
|
+
status: parsed.pathCount === 0 ? 'empty' : 'generated',
|
|
730
|
+
pathCount: parsed.pathCount,
|
|
731
|
+
tracedScale: parsed.tracedScale,
|
|
732
|
+
bytes: parsed.bytes,
|
|
733
|
+
},
|
|
734
|
+
artifact,
|
|
735
|
+
...(warning.length === 0 ? {} : { warning: warning.split(/\r?\n/).slice(-1)[0] ?? warning }),
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
finally {
|
|
739
|
+
await rm(staged, { force: true }).catch(() => { });
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
/** pixel_diff: compare real pixels, rank error regions, and deliver a heatmap plus JSON report. */
|
|
744
|
+
async pixelDiff(request, options) {
|
|
745
|
+
return this.runOperation('vision_pixel_diff', options, async (operation) => {
|
|
746
|
+
const grid = integerInRange(request.grid, 6, 1, 32, 'pixel_diff.grid');
|
|
747
|
+
const top = integerInRange(request.top, 5, 1, grid * grid, 'pixel_diff.top');
|
|
748
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
749
|
+
const original = await this.validateImage(request.original, policy, operation);
|
|
750
|
+
const rebuilt = await this.validateImage(request.rebuilt, policy, operation);
|
|
751
|
+
this.accountImage(original, operation);
|
|
752
|
+
this.accountImage(rebuilt, operation);
|
|
753
|
+
const originalStem = basename(original.path, extname(original.path));
|
|
754
|
+
const rebuiltStem = basename(rebuilt.path, extname(rebuilt.path));
|
|
755
|
+
const finalDirectory = resolveOutputDirectory(request.runName, policy, `${originalStem}-vs-${rebuiltStem}.pixel-diff`);
|
|
756
|
+
if (isWithin(finalDirectory, original.path) || isWithin(finalDirectory, rebuilt.path)) {
|
|
757
|
+
throw new VisionToolkitError('input', 'pixel_diff artifact directory would replace an input image');
|
|
758
|
+
}
|
|
759
|
+
const stagedDirectory = await createStagedDirectory(policy);
|
|
760
|
+
const stagedHeatmap = join(stagedDirectory, 'heatmap.png');
|
|
761
|
+
const stagedReport = join(stagedDirectory, 'report.json');
|
|
762
|
+
try {
|
|
763
|
+
const result = await this.runUpstream('pixel_diff', [
|
|
764
|
+
original.path,
|
|
765
|
+
rebuilt.path,
|
|
766
|
+
'--grid',
|
|
767
|
+
String(grid),
|
|
768
|
+
'--top',
|
|
769
|
+
String(top),
|
|
770
|
+
'-o',
|
|
771
|
+
stagedHeatmap,
|
|
772
|
+
], operation);
|
|
773
|
+
const parsed = parsePixelDiffOutput(result.stdout);
|
|
774
|
+
if (parsed.heatmapPath !== stagedHeatmap) {
|
|
775
|
+
throw new VisionToolkitError('output', 'pixel_diff: upstream reported an unexpected heatmap path');
|
|
776
|
+
}
|
|
777
|
+
if (!Number.isFinite(parsed.overallDifferencePct) || parsed.overallDifferencePct < 0 || parsed.overallDifferencePct > 100) {
|
|
778
|
+
throw new VisionToolkitError('output', 'pixel_diff: overall difference is outside 0-100%');
|
|
779
|
+
}
|
|
780
|
+
if (parsed.scaled !== (original.width !== rebuilt.width || original.height !== rebuilt.height)
|
|
781
|
+
|| (parsed.scaledToSize !== undefined
|
|
782
|
+
&& (parsed.scaledToSize.width !== original.width || parsed.scaledToSize.height !== original.height))
|
|
783
|
+
|| parsed.worstRegions.length > top
|
|
784
|
+
|| parsed.worstRegions.some((region, index) => region.index !== index + 1)) {
|
|
785
|
+
throw new VisionToolkitError('output', 'pixel_diff: scaling or ranked-region metadata is inconsistent');
|
|
786
|
+
}
|
|
787
|
+
for (const region of parsed.worstRegions)
|
|
788
|
+
assertBoxWithin(region.box, original.width, original.height, 'pixel_diff');
|
|
789
|
+
const heatmapInfo = await this.probeGeneratedImage(stagedHeatmap, operation, 'pixel_diff');
|
|
790
|
+
if (heatmapInfo.format !== 'png' || heatmapInfo.width !== original.width || heatmapInfo.height !== original.height) {
|
|
791
|
+
throw new VisionToolkitError('output', 'pixel_diff: heatmap dimensions or format do not match the reference image');
|
|
792
|
+
}
|
|
793
|
+
const reportPayload = {
|
|
794
|
+
schemaVersion: 1,
|
|
795
|
+
sourceTool: 'vision_pixel_diff',
|
|
796
|
+
original,
|
|
797
|
+
rebuilt,
|
|
798
|
+
scaled: parsed.scaled,
|
|
799
|
+
...(parsed.rebuiltOriginalSize === undefined ? {} : { rebuiltOriginalSize: parsed.rebuiltOriginalSize }),
|
|
800
|
+
overallDifferencePct: parsed.overallDifferencePct,
|
|
801
|
+
grid,
|
|
802
|
+
worstRegions: parsed.worstRegions,
|
|
803
|
+
};
|
|
804
|
+
await writeFile(stagedReport, `${JSON.stringify(reportPayload, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
|
|
805
|
+
await commitStagedDirectory(stagedDirectory, finalDirectory, policy);
|
|
806
|
+
const heatmapPath = join(finalDirectory, 'heatmap.png');
|
|
807
|
+
const reportPath = join(finalDirectory, 'report.json');
|
|
808
|
+
const [heatmap, report] = await Promise.all([
|
|
809
|
+
describeArtifact(heatmapPath, policy, {
|
|
810
|
+
mimeType: 'image/png',
|
|
811
|
+
kind: 'image',
|
|
812
|
+
description: 'Pixel-difference heatmap',
|
|
813
|
+
sourceTool: 'vision_pixel_diff',
|
|
814
|
+
previewIntent: 'image',
|
|
815
|
+
}),
|
|
816
|
+
describeArtifact(reportPath, policy, {
|
|
817
|
+
mimeType: 'application/json',
|
|
818
|
+
kind: 'json',
|
|
819
|
+
description: 'Structured pixel-difference report',
|
|
820
|
+
sourceTool: 'vision_pixel_diff',
|
|
821
|
+
previewIntent: 'text',
|
|
822
|
+
}),
|
|
823
|
+
]);
|
|
824
|
+
return {
|
|
825
|
+
original,
|
|
826
|
+
rebuilt,
|
|
827
|
+
scaled: parsed.scaled,
|
|
828
|
+
...(parsed.rebuiltOriginalSize === undefined ? {} : { rebuiltOriginalSize: parsed.rebuiltOriginalSize }),
|
|
829
|
+
overallDifferencePct: parsed.overallDifferencePct,
|
|
830
|
+
worstRegions: parsed.worstRegions,
|
|
831
|
+
heatmap,
|
|
832
|
+
report,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
await rm(stagedDirectory, { recursive: true, force: true }).catch(() => { });
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
/** long_screenshot_ocr: split safely, optionally OCR, and atomically deliver the complete audit run. */
|
|
841
|
+
async longScreenshotOcr(request, options) {
|
|
842
|
+
const jobs = integerInRange(request.jobs, Math.min(2, this.config.concurrency), 1, this.config.concurrency, 'long_screenshot_ocr.jobs');
|
|
843
|
+
const splitOnly = request.splitOnly === true;
|
|
844
|
+
const permits = splitOnly ? 1 : jobs;
|
|
845
|
+
return this.runOperation('vision_long_screenshot_ocr', options, async (operation) => {
|
|
846
|
+
const mode = request.mode ?? 'general';
|
|
847
|
+
if (mode !== 'general' && mode !== 'chat')
|
|
848
|
+
throw new VisionToolkitError('input', 'long_screenshot_ocr.mode must be general or chat');
|
|
849
|
+
const targetHeight = request.targetHeight === undefined
|
|
850
|
+
? undefined
|
|
851
|
+
: integerInRange(request.targetHeight, request.targetHeight, 64, 100000, 'long_screenshot_ocr.targetHeight');
|
|
852
|
+
const minHeight = request.minHeight === undefined
|
|
853
|
+
? undefined
|
|
854
|
+
: integerInRange(request.minHeight, request.minHeight, 64, 100000, 'long_screenshot_ocr.minHeight');
|
|
855
|
+
const maxHeight = request.maxHeight === undefined
|
|
856
|
+
? undefined
|
|
857
|
+
: integerInRange(request.maxHeight, request.maxHeight, 64, 100000, 'long_screenshot_ocr.maxHeight');
|
|
858
|
+
if (minHeight !== undefined && maxHeight !== undefined && minHeight > maxHeight) {
|
|
859
|
+
throw new VisionToolkitError('input', 'long_screenshot_ocr.minHeight must not exceed maxHeight');
|
|
860
|
+
}
|
|
861
|
+
if (targetHeight !== undefined && minHeight !== undefined && targetHeight < minHeight) {
|
|
862
|
+
throw new VisionToolkitError('input', 'long_screenshot_ocr.targetHeight must not be below minHeight');
|
|
863
|
+
}
|
|
864
|
+
if (targetHeight !== undefined && maxHeight !== undefined && targetHeight > maxHeight) {
|
|
865
|
+
throw new VisionToolkitError('input', 'long_screenshot_ocr.targetHeight must not exceed maxHeight');
|
|
866
|
+
}
|
|
867
|
+
const overlap = request.overlap === undefined
|
|
868
|
+
? undefined
|
|
869
|
+
: integerInRange(request.overlap, request.overlap, 0, 10000, 'long_screenshot_ocr.overlap');
|
|
870
|
+
const chunkTimeoutSeconds = finiteInRange(request.chunkTimeoutSeconds ?? Math.min(180, Math.max(1, Math.ceil(this.timeout(options) / 1000))), 1, 600, 'long_screenshot_ocr.chunkTimeoutSeconds');
|
|
871
|
+
if (chunkTimeoutSeconds === undefined)
|
|
872
|
+
throw new VisionToolkitError('input', 'long_screenshot_ocr chunk timeout is required');
|
|
873
|
+
if (request.prompt !== undefined && request.prompt.trim().length === 0) {
|
|
874
|
+
throw new VisionToolkitError('input', 'long_screenshot_ocr.prompt must not be empty when provided');
|
|
875
|
+
}
|
|
876
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
877
|
+
const image = await this.validateImage(request.image, policy, operation);
|
|
878
|
+
this.accountImage(image, operation);
|
|
879
|
+
const stem = basename(image.path, extname(image.path));
|
|
880
|
+
const finalDirectory = resolveOutputDirectory(request.runName, policy, `${stem}.long-ocr`);
|
|
881
|
+
if (isWithin(finalDirectory, image.path)) {
|
|
882
|
+
throw new VisionToolkitError('input', 'long_screenshot_ocr artifact directory would replace the input image');
|
|
883
|
+
}
|
|
884
|
+
const stagedDirectory = await createStagedDirectory(policy);
|
|
885
|
+
try {
|
|
886
|
+
if (request.resume === true)
|
|
887
|
+
await seedStagedDirectory(finalDirectory, stagedDirectory, policy);
|
|
888
|
+
const stagedPolicy = { ...policy, outputDir: stagedDirectory };
|
|
889
|
+
const stagedOutput = resolveOutputFile(request.output, stagedPolicy, `${stem}.ocr.md`, ['.md', '.markdown']);
|
|
890
|
+
const finalOutput = join(finalDirectory, basename(stagedOutput));
|
|
891
|
+
const stagedChunks = join(stagedDirectory, 'chunks');
|
|
892
|
+
const stagedManifest = join(stagedChunks, 'manifest.json');
|
|
893
|
+
const result = await this.runUpstream('long_screenshot_ocr', [
|
|
894
|
+
image.path,
|
|
895
|
+
'--mode',
|
|
896
|
+
mode,
|
|
897
|
+
'-o',
|
|
898
|
+
stagedOutput,
|
|
899
|
+
'--chunks-dir',
|
|
900
|
+
stagedChunks,
|
|
901
|
+
...(targetHeight === undefined ? [] : ['--target-height', String(targetHeight)]),
|
|
902
|
+
...(minHeight === undefined ? [] : ['--min-height', String(minHeight)]),
|
|
903
|
+
...(maxHeight === undefined ? [] : ['--max-height', String(maxHeight)]),
|
|
904
|
+
...(overlap === undefined ? [] : ['--overlap', String(overlap)]),
|
|
905
|
+
...(request.prompt === undefined ? [] : ['--prompt', request.prompt]),
|
|
906
|
+
'--jobs',
|
|
907
|
+
String(jobs),
|
|
908
|
+
'--timeout',
|
|
909
|
+
String(chunkTimeoutSeconds),
|
|
910
|
+
...(splitOnly ? ['--split-only'] : []),
|
|
911
|
+
...(request.resume === true ? ['--resume'] : []),
|
|
912
|
+
], operation, splitOnly ? undefined : await this.resolveVisionEnv());
|
|
913
|
+
const reported = result.stdout.trim();
|
|
914
|
+
const expectedReported = splitOnly ? stagedManifest : stagedOutput;
|
|
915
|
+
if (reported !== expectedReported) {
|
|
916
|
+
throw new VisionToolkitError('output', 'long_screenshot_ocr: upstream reported an unexpected output path');
|
|
917
|
+
}
|
|
918
|
+
const parsedManifest = parseLongOcrManifest(await readFile(stagedManifest, 'utf8'), {
|
|
919
|
+
source: image.path,
|
|
920
|
+
output: stagedOutput,
|
|
921
|
+
width: image.width,
|
|
922
|
+
height: image.height,
|
|
923
|
+
mode,
|
|
924
|
+
splitOnly,
|
|
925
|
+
});
|
|
926
|
+
for (const chunk of parsedManifest.chunks) {
|
|
927
|
+
const chunkBytes = await readFile(join(stagedChunks, chunk.image));
|
|
928
|
+
if (createHash('sha256').update(chunkBytes).digest('hex') !== chunk.imageSha256) {
|
|
929
|
+
throw new VisionToolkitError('output', `long_screenshot_ocr: chunk ${chunk.index} hash does not match the manifest`);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
parsedManifest.raw.output = splitOnly ? null : finalOutput;
|
|
933
|
+
await writeFile(stagedManifest, `${JSON.stringify(parsedManifest.raw, null, 2)}\n`, 'utf8');
|
|
934
|
+
await commitStagedDirectory(stagedDirectory, finalDirectory, policy);
|
|
935
|
+
const finalChunks = join(finalDirectory, 'chunks');
|
|
936
|
+
const manifest = await describeArtifact(join(finalChunks, 'manifest.json'), policy, {
|
|
937
|
+
mimeType: 'application/json',
|
|
938
|
+
kind: 'json',
|
|
939
|
+
description: 'Long-screenshot split and merge manifest',
|
|
940
|
+
sourceTool: 'vision_long_screenshot_ocr',
|
|
941
|
+
previewIntent: 'text',
|
|
942
|
+
});
|
|
943
|
+
const output = splitOnly
|
|
944
|
+
? undefined
|
|
945
|
+
: await describeArtifact(finalOutput, policy, {
|
|
946
|
+
mimeType: 'text/markdown',
|
|
947
|
+
kind: 'markdown',
|
|
948
|
+
description: 'Merged long-screenshot OCR transcript',
|
|
949
|
+
sourceTool: 'vision_long_screenshot_ocr',
|
|
950
|
+
previewIntent: 'text',
|
|
951
|
+
});
|
|
952
|
+
const audit = splitOnly
|
|
953
|
+
? undefined
|
|
954
|
+
: await describeArtifact(join(finalChunks, 'ocr_audit.md'), policy, {
|
|
955
|
+
mimeType: 'text/markdown',
|
|
956
|
+
kind: 'markdown',
|
|
957
|
+
description: 'Long-screenshot OCR boundary audit',
|
|
958
|
+
sourceTool: 'vision_long_screenshot_ocr',
|
|
959
|
+
previewIntent: 'text',
|
|
960
|
+
});
|
|
961
|
+
const chunks = [];
|
|
962
|
+
for (const chunk of parsedManifest.chunks) {
|
|
963
|
+
const chunkImage = await describeArtifact(join(finalChunks, chunk.image), policy, {
|
|
964
|
+
mimeType: 'image/png',
|
|
965
|
+
kind: 'image',
|
|
966
|
+
description: `Long-screenshot OCR chunk ${chunk.index}`,
|
|
967
|
+
sourceTool: 'vision_long_screenshot_ocr',
|
|
968
|
+
previewIntent: 'image',
|
|
969
|
+
});
|
|
970
|
+
const ocr = chunk.ocr === undefined
|
|
971
|
+
? undefined
|
|
972
|
+
: await describeArtifact(join(finalChunks, chunk.ocr), policy, {
|
|
973
|
+
mimeType: mode === 'chat' ? 'application/json' : 'text/markdown',
|
|
974
|
+
kind: mode === 'chat' ? 'json' : 'markdown',
|
|
975
|
+
description: `OCR sidecar for chunk ${chunk.index}`,
|
|
976
|
+
sourceTool: 'vision_long_screenshot_ocr',
|
|
977
|
+
previewIntent: 'text',
|
|
978
|
+
});
|
|
979
|
+
chunks.push({
|
|
980
|
+
index: chunk.index,
|
|
981
|
+
coreTop: chunk.coreTop,
|
|
982
|
+
coreBottom: chunk.coreBottom,
|
|
983
|
+
cropTop: chunk.cropTop,
|
|
984
|
+
cropBottom: chunk.cropBottom,
|
|
985
|
+
image: chunkImage,
|
|
986
|
+
...(ocr === undefined ? {} : { ocr }),
|
|
987
|
+
...(chunk.ocrReused === undefined ? {} : { reused: chunk.ocrReused }),
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
return {
|
|
991
|
+
source: image,
|
|
992
|
+
mode,
|
|
993
|
+
splitOnly,
|
|
994
|
+
complete: parsedManifest.complete,
|
|
995
|
+
chunkCount: chunks.length,
|
|
996
|
+
runDirectory: finalDirectory,
|
|
997
|
+
...(output === undefined ? {} : { output }),
|
|
998
|
+
manifest,
|
|
999
|
+
...(audit === undefined ? {} : { audit }),
|
|
1000
|
+
chunks,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
finally {
|
|
1004
|
+
await rm(stagedDirectory, { recursive: true, force: true }).catch(() => { });
|
|
1005
|
+
}
|
|
1006
|
+
}, permits);
|
|
1007
|
+
}
|
|
1008
|
+
/** extract_foreground: preserve the pinned component selection and deliver an RGBA PNG. */
|
|
1009
|
+
async extractForeground(request, options) {
|
|
1010
|
+
return this.runOperation('vision_extract_foreground', options, async (operation) => {
|
|
1011
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
1012
|
+
const image = await this.validateImage(request.image, policy, operation);
|
|
1013
|
+
this.accountImage(image, operation);
|
|
1014
|
+
const region = request.region === undefined ? undefined : parseRegion(request.region);
|
|
1015
|
+
if (region !== undefined)
|
|
1016
|
+
assertBoxWithin(region, image.width, image.height, 'extract_foreground.region');
|
|
1017
|
+
const boxes = request.boxes === undefined ? undefined : parseRegion(request.boxes);
|
|
1018
|
+
if (boxes !== undefined)
|
|
1019
|
+
assertBoxWithin(boxes, image.width, image.height, 'extract_foreground.boxes');
|
|
1020
|
+
const discRadius = finiteInRange(request.discRadius, 1, Math.max(image.width, image.height) * 4, 'extract_foreground.discRadius');
|
|
1021
|
+
const saturation = integerInRange(request.saturation, 12, 0, 255, 'extract_foreground.saturation');
|
|
1022
|
+
const darkThreshold = integerInRange(request.darkThreshold, 215, 0, 255, 'extract_foreground.darkThreshold');
|
|
1023
|
+
const excludeTolerance = finiteInRange(request.excludeTolerance ?? 24, 0, 442, 'extract_foreground.excludeTolerance');
|
|
1024
|
+
const padding = integerInRange(request.padding, 3, 0, 4096, 'extract_foreground.padding');
|
|
1025
|
+
const excludeColor = request.excludeColor === undefined
|
|
1026
|
+
? undefined
|
|
1027
|
+
: `#${request.excludeColor.trim().replace(/^#/, '').toUpperCase()}`;
|
|
1028
|
+
if (excludeColor !== undefined && !HEX_COLOR_PATTERN.test(excludeColor)) {
|
|
1029
|
+
throw new VisionToolkitError('input', 'extract_foreground.excludeColor must be #RRGGBB');
|
|
1030
|
+
}
|
|
1031
|
+
const extension = extname(image.path).toLowerCase();
|
|
1032
|
+
const stem = basename(image.path, extension);
|
|
1033
|
+
const finalPath = resolveOutputFile(request.output, policy, `${stem}.foreground.png`, ['.png']);
|
|
1034
|
+
assertDistinctOutput(image.path, finalPath);
|
|
1035
|
+
const staged = createStagedOutput(policy, '.png');
|
|
1036
|
+
try {
|
|
1037
|
+
const result = await this.runUpstream('extract_foreground', [
|
|
1038
|
+
image.path,
|
|
1039
|
+
...(region === undefined ? [] : ['--region', `${region.x1},${region.y1},${region.x2},${region.y2}`]),
|
|
1040
|
+
...(boxes === undefined ? [] : ['--boxes', `${boxes.x1},${boxes.y1},${boxes.x2},${boxes.y2}`]),
|
|
1041
|
+
'--mode',
|
|
1042
|
+
request.mode ?? 'color',
|
|
1043
|
+
'--sat',
|
|
1044
|
+
String(saturation),
|
|
1045
|
+
'--dark',
|
|
1046
|
+
String(darkThreshold),
|
|
1047
|
+
'--exclude-tol',
|
|
1048
|
+
String(excludeTolerance),
|
|
1049
|
+
'--pad',
|
|
1050
|
+
String(padding),
|
|
1051
|
+
...(discRadius === undefined ? [] : ['--disc-radius', String(discRadius)]),
|
|
1052
|
+
...(excludeColor === undefined ? [] : ['--exclude-color', excludeColor]),
|
|
1053
|
+
...(request.keepWhites === false ? ['--no-keep-whites'] : []),
|
|
1054
|
+
'-o',
|
|
1055
|
+
staged,
|
|
1056
|
+
], operation);
|
|
1057
|
+
const parsed = parseExtractForegroundOutput(result.stdout);
|
|
1058
|
+
if (parsed.outputPath !== staged)
|
|
1059
|
+
throw new VisionToolkitError('output', 'extract_foreground: upstream reported an unexpected output path');
|
|
1060
|
+
assertBoxWithin(parsed.box, image.width, image.height, 'extract_foreground');
|
|
1061
|
+
if (parsed.foregroundPixels <= 0
|
|
1062
|
+
|| parsed.keptComponents <= 0
|
|
1063
|
+
|| parsed.totalComponents < parsed.keptComponents
|
|
1064
|
+
|| parsed.largestComponentPct < 0
|
|
1065
|
+
|| parsed.largestComponentPct > 100) {
|
|
1066
|
+
throw new VisionToolkitError('output', 'extract_foreground: component metrics are invalid');
|
|
1067
|
+
}
|
|
1068
|
+
const generated = await this.probeGeneratedImage(staged, operation, 'extract_foreground');
|
|
1069
|
+
if (generated.format !== 'png'
|
|
1070
|
+
|| (generated.mode !== 'RGBA' && generated.mode !== 'LA')
|
|
1071
|
+
|| generated.width !== parsed.width
|
|
1072
|
+
|| generated.height !== parsed.height) {
|
|
1073
|
+
throw new VisionToolkitError('output', 'extract_foreground: output is not the reported transparent PNG');
|
|
1074
|
+
}
|
|
1075
|
+
await commitStagedOutput(staged, finalPath, policy);
|
|
1076
|
+
const artifact = await describeArtifact(finalPath, policy, {
|
|
1077
|
+
mimeType: 'image/png',
|
|
1078
|
+
kind: 'image',
|
|
1079
|
+
description: 'Extracted transparent foreground',
|
|
1080
|
+
sourceTool: 'vision_extract_foreground',
|
|
1081
|
+
previewIntent: 'image',
|
|
1082
|
+
});
|
|
1083
|
+
return {
|
|
1084
|
+
source: image,
|
|
1085
|
+
box: parsed.box,
|
|
1086
|
+
foregroundPixels: parsed.foregroundPixels,
|
|
1087
|
+
keptComponents: parsed.keptComponents,
|
|
1088
|
+
totalComponents: parsed.totalComponents,
|
|
1089
|
+
largestComponentPct: parsed.largestComponentPct,
|
|
1090
|
+
width: parsed.width,
|
|
1091
|
+
height: parsed.height,
|
|
1092
|
+
artifact,
|
|
1093
|
+
...(parsed.autoSummary === undefined ? {} : { autoSummary: parsed.autoSummary }),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
finally {
|
|
1097
|
+
await rm(staged, { force: true }).catch(() => { });
|
|
1098
|
+
}
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
/** dominant_colors: expose palette clusters or candidate scores as structure, never stdout prose. */
|
|
1102
|
+
async dominantColors(request, options) {
|
|
1103
|
+
return this.runOperation('vision_dominant_colors', options, async (operation) => {
|
|
1104
|
+
const top = integerInRange(request.top, 5, 1, 64, 'dominant_colors.top');
|
|
1105
|
+
const quantize = integerInRange(request.quantize, 16, 2, 256, 'dominant_colors.quantize');
|
|
1106
|
+
const maxPixels = integerInRange(request.maxPixels, 96, 8, 4096, 'dominant_colors.maxPixels');
|
|
1107
|
+
const mergeTolerance = integerInRange(request.mergeTolerance, 8, 0, 255, 'dominant_colors.mergeTolerance');
|
|
1108
|
+
const candidateTolerance = integerInRange(request.candidateTolerance, 16, 0, 255, 'dominant_colors.candidateTolerance');
|
|
1109
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
1110
|
+
const image = await this.validateImage(request.image, policy, operation);
|
|
1111
|
+
this.accountImage(image, operation);
|
|
1112
|
+
const region = request.region === undefined ? undefined : parseRegion(request.region);
|
|
1113
|
+
if (region !== undefined)
|
|
1114
|
+
assertBoxWithin(region, image.width, image.height, 'dominant_colors.region');
|
|
1115
|
+
const candidates = request.candidates?.map(value => `#${value.trim().replace(/^#/, '').toUpperCase()}`);
|
|
1116
|
+
if (candidates !== undefined) {
|
|
1117
|
+
if (candidates.length === 0 || candidates.length > 32) {
|
|
1118
|
+
throw new VisionToolkitError('input', 'dominant_colors.candidates must contain between 1 and 32 colors');
|
|
1119
|
+
}
|
|
1120
|
+
if (candidates.some(candidate => !HEX_COLOR_PATTERN.test(candidate))) {
|
|
1121
|
+
throw new VisionToolkitError('input', 'dominant_colors.candidates must contain only #RRGGBB colors');
|
|
1122
|
+
}
|
|
1123
|
+
if (new Set(candidates).size !== candidates.length) {
|
|
1124
|
+
throw new VisionToolkitError('input', 'dominant_colors.candidates must not contain duplicates');
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
const result = await this.runUpstream('dominant_colors', [
|
|
1128
|
+
image.path,
|
|
1129
|
+
...(region === undefined ? [] : ['--region', `${region.x1},${region.y1},${region.x2},${region.y2}`]),
|
|
1130
|
+
...(candidates === undefined ? [] : ['--candidates', candidates.join(',')]),
|
|
1131
|
+
'--top',
|
|
1132
|
+
String(top),
|
|
1133
|
+
'--quantize',
|
|
1134
|
+
String(quantize),
|
|
1135
|
+
'--max-pixels',
|
|
1136
|
+
String(maxPixels),
|
|
1137
|
+
'--merge-tol',
|
|
1138
|
+
String(mergeTolerance),
|
|
1139
|
+
'--tol',
|
|
1140
|
+
String(candidateTolerance),
|
|
1141
|
+
], operation);
|
|
1142
|
+
const analysis = parseDominantColorsOutput(result.stdout);
|
|
1143
|
+
assertBoxWithin(analysis.region, image.width, image.height, 'dominant_colors');
|
|
1144
|
+
if (analysis.width !== analysis.region.x2 - analysis.region.x1 || analysis.height !== analysis.region.y2 - analysis.region.y1) {
|
|
1145
|
+
throw new VisionToolkitError('output', 'dominant_colors: reported region dimensions are inconsistent');
|
|
1146
|
+
}
|
|
1147
|
+
if (candidates !== undefined) {
|
|
1148
|
+
if (analysis.mode !== 'candidates')
|
|
1149
|
+
throw new VisionToolkitError('output', 'dominant_colors: expected candidate mode output');
|
|
1150
|
+
if (analysis.candidates.map(candidate => candidate.color).join(',') !== candidates.join(',')) {
|
|
1151
|
+
throw new VisionToolkitError('output', 'dominant_colors: candidate rows do not match the request');
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
else if (analysis.mode !== 'palette') {
|
|
1155
|
+
throw new VisionToolkitError('output', 'dominant_colors: expected palette mode output');
|
|
1156
|
+
}
|
|
1157
|
+
return { image, analysis };
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
/** html_screenshot: render only a path-fenced local HTML file in the pinned Chrome adapter. */
|
|
1161
|
+
async htmlScreenshot(request, options) {
|
|
1162
|
+
return this.runOperation('vision_html_screenshot', options, async (operation) => {
|
|
1163
|
+
const width = integerInRange(request.width, 1280, 1, 8192, 'html_screenshot.width');
|
|
1164
|
+
const height = integerInRange(request.height, 800, 1, 8192, 'html_screenshot.height');
|
|
1165
|
+
const scale = integerInRange(request.scale, 1, 1, 4, 'html_screenshot.scale');
|
|
1166
|
+
const waitMs = integerInRange(request.waitMs, 0, 0, 120000, 'html_screenshot.waitMs');
|
|
1167
|
+
const outputPixels = width * height * scale * scale;
|
|
1168
|
+
if (!Number.isSafeInteger(outputPixels) || outputPixels > this.config.maxImagePixels) {
|
|
1169
|
+
throw new VisionToolkitError('capacity', `HTML screenshot would create ${outputPixels} pixels, exceeding maxImagePixels ${this.config.maxImagePixels}`);
|
|
1170
|
+
}
|
|
1171
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
1172
|
+
const source = await resolveHtmlFile(request.source, policy);
|
|
1173
|
+
if (source.bytes > this.config.maxImageBytes) {
|
|
1174
|
+
throw new VisionToolkitError('capacity', `HTML source is ${source.bytes} bytes, exceeding maxImageBytes ${this.config.maxImageBytes}`);
|
|
1175
|
+
}
|
|
1176
|
+
const stem = basename(source.path, extname(source.path));
|
|
1177
|
+
const finalPath = resolveOutputFile(request.output, policy, `${stem}.screenshot.png`, ['.png']);
|
|
1178
|
+
assertDistinctOutput(source.path, finalPath);
|
|
1179
|
+
const staged = createStagedOutput(policy, '.png');
|
|
1180
|
+
try {
|
|
1181
|
+
const result = await this.runUpstream('html_screenshot', [
|
|
1182
|
+
source.path,
|
|
1183
|
+
'-o',
|
|
1184
|
+
staged,
|
|
1185
|
+
'--width',
|
|
1186
|
+
String(width),
|
|
1187
|
+
'--height',
|
|
1188
|
+
String(height),
|
|
1189
|
+
'--scale',
|
|
1190
|
+
String(scale),
|
|
1191
|
+
'--wait-ms',
|
|
1192
|
+
String(waitMs),
|
|
1193
|
+
], operation);
|
|
1194
|
+
const parsed = parseHtmlScreenshotOutput(result.stdout);
|
|
1195
|
+
const expectedWidth = width * scale;
|
|
1196
|
+
const expectedHeight = height * scale;
|
|
1197
|
+
if (parsed.outputPath !== staged || parsed.width !== expectedWidth || parsed.height !== expectedHeight) {
|
|
1198
|
+
throw new VisionToolkitError('output', 'html_screenshot: upstream summary does not match the requested output');
|
|
1199
|
+
}
|
|
1200
|
+
const generated = await this.probeGeneratedImage(staged, operation, 'html_screenshot');
|
|
1201
|
+
if (generated.format !== 'png' || generated.width !== expectedWidth || generated.height !== expectedHeight) {
|
|
1202
|
+
throw new VisionToolkitError('output', 'html_screenshot: generated PNG dimensions do not match the viewport');
|
|
1203
|
+
}
|
|
1204
|
+
await commitStagedOutput(staged, finalPath, policy);
|
|
1205
|
+
const artifact = await describeArtifact(finalPath, policy, {
|
|
1206
|
+
mimeType: 'image/png',
|
|
1207
|
+
kind: 'image',
|
|
1208
|
+
description: 'Headless browser screenshot of local HTML',
|
|
1209
|
+
sourceTool: 'vision_html_screenshot',
|
|
1210
|
+
previewIntent: 'image',
|
|
1211
|
+
});
|
|
1212
|
+
return {
|
|
1213
|
+
sourcePath: source.path,
|
|
1214
|
+
sourceBytes: source.bytes,
|
|
1215
|
+
viewport: { width, height, scale },
|
|
1216
|
+
width: expectedWidth,
|
|
1217
|
+
height: expectedHeight,
|
|
1218
|
+
artifact,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
finally {
|
|
1222
|
+
await rm(staged, { force: true }).catch(() => { });
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
async writableDirectoryCheck(path, label) {
|
|
1227
|
+
const probe = join(path, `.vision-toolkit-health-${randomUUID()}`);
|
|
1228
|
+
try {
|
|
1229
|
+
await writeFile(probe, 'ok\n', { encoding: 'utf8', flag: 'wx' });
|
|
1230
|
+
await rm(probe, { force: true });
|
|
1231
|
+
return { status: 'ok', detail: `${label} is writable: ${path}` };
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
await rm(probe, { force: true }).catch(() => { });
|
|
1235
|
+
return { status: 'error', detail: `${label} is not writable: ${path}` };
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
/** health: inspect local readiness and optionally probe the configured `/models` endpoint. */
|
|
1239
|
+
async health(testConnection, options) {
|
|
1240
|
+
return this.runOperation('vision_toolkit_health', options, async (operation) => {
|
|
1241
|
+
const info = this.upstreamVersion;
|
|
1242
|
+
const python = { status: 'ok', detail: `${info.pythonVersion} via ${info.python}` };
|
|
1243
|
+
const dependencies = {
|
|
1244
|
+
status: 'ok',
|
|
1245
|
+
detail: Object.entries(info.dependencies).map(([name, version]) => `${name}=${version}`).join(', '),
|
|
1246
|
+
};
|
|
1247
|
+
let chrome;
|
|
1248
|
+
try {
|
|
1249
|
+
const started = Date.now();
|
|
1250
|
+
const chromePath = await this.adapter.findChrome({ signal: operation.signal });
|
|
1251
|
+
operation.metrics.upstreamMs += Date.now() - started;
|
|
1252
|
+
chrome = chromePath === undefined
|
|
1253
|
+
? { status: 'warning', detail: 'Chrome/Chromium/Edge was not found; vision_html_screenshot is unavailable' }
|
|
1254
|
+
: { status: 'ok', detail: chromePath };
|
|
1255
|
+
}
|
|
1256
|
+
catch {
|
|
1257
|
+
if (operation.signal.aborted)
|
|
1258
|
+
throw new VisionToolkitError('cancelled', 'vision_toolkit_health: cancelled');
|
|
1259
|
+
chrome = { status: 'error', detail: 'Chrome availability probe failed' };
|
|
1260
|
+
}
|
|
1261
|
+
let resolvedCredential;
|
|
1262
|
+
let credential;
|
|
1263
|
+
try {
|
|
1264
|
+
resolvedCredential = await this.ctx.credentials.resolve(this.config.provider.credential);
|
|
1265
|
+
credential = resolvedCredential === undefined
|
|
1266
|
+
? { status: 'error', detail: `credential ${this.config.provider.credential} is not configured` }
|
|
1267
|
+
: { status: 'ok', detail: `credential ${this.config.provider.credential} is resolvable` };
|
|
1268
|
+
}
|
|
1269
|
+
catch {
|
|
1270
|
+
credential = { status: 'error', detail: `credential ${this.config.provider.credential} could not be resolved` };
|
|
1271
|
+
}
|
|
1272
|
+
let artifactDirectory;
|
|
1273
|
+
try {
|
|
1274
|
+
const policy = await this.pathPolicy(options.workspace);
|
|
1275
|
+
artifactDirectory = await this.writableDirectoryCheck(policy.outputDir, 'Artifact directory');
|
|
1276
|
+
}
|
|
1277
|
+
catch {
|
|
1278
|
+
artifactDirectory = { status: 'error', detail: 'Artifact directory could not be prepared' };
|
|
1279
|
+
}
|
|
1280
|
+
const tempDirectory = await this.writableDirectoryCheck(info.runtimeHome, 'Runtime temp directory');
|
|
1281
|
+
let service = {
|
|
1282
|
+
status: 'not_tested',
|
|
1283
|
+
detail: 'Connection was not tested; pass testConnection=true to query the configured /models endpoint',
|
|
1284
|
+
};
|
|
1285
|
+
if (testConnection) {
|
|
1286
|
+
if (resolvedCredential === undefined) {
|
|
1287
|
+
service = { status: 'error', detail: 'Connection test skipped because the configured credential is unavailable' };
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
operation.metrics.usedVisionService = true;
|
|
1291
|
+
const endpoint = `${this.config.provider.baseUrl}/models`;
|
|
1292
|
+
try {
|
|
1293
|
+
const started = Date.now();
|
|
1294
|
+
const response = await fetch(endpoint, {
|
|
1295
|
+
method: 'GET',
|
|
1296
|
+
headers: { Authorization: `Bearer ${resolvedCredential.value}`, Accept: 'application/json' },
|
|
1297
|
+
signal: operation.signal,
|
|
1298
|
+
});
|
|
1299
|
+
operation.metrics.upstreamMs += Date.now() - started;
|
|
1300
|
+
await response.body?.cancel().catch(() => { });
|
|
1301
|
+
if (response.ok) {
|
|
1302
|
+
service = { status: 'ok', detail: `Service responded at ${endpoint} (HTTP ${response.status})` };
|
|
1303
|
+
}
|
|
1304
|
+
else if (response.status === 401 || response.status === 403) {
|
|
1305
|
+
service = { status: 'error', detail: `Service rejected the configured credential (HTTP ${response.status})` };
|
|
1306
|
+
}
|
|
1307
|
+
else if (response.status === 404 || response.status === 405) {
|
|
1308
|
+
service = { status: 'warning', detail: `Service is reachable but does not expose GET /models (HTTP ${response.status})` };
|
|
1309
|
+
}
|
|
1310
|
+
else if (response.status === 429) {
|
|
1311
|
+
service = { status: 'warning', detail: 'Service is reachable but rate-limited the connection test (HTTP 429)' };
|
|
1312
|
+
}
|
|
1313
|
+
else {
|
|
1314
|
+
service = { status: 'error', detail: `Service connection test failed with HTTP ${response.status}` };
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
if (operation.signal.aborted)
|
|
1319
|
+
throw new VisionToolkitError('cancelled', 'vision_toolkit_health: connection test cancelled');
|
|
1320
|
+
service = { status: 'error', detail: `Service could not be reached at ${endpoint}` };
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service };
|
|
1325
|
+
const healthy = Object.values(checks).every(check => check.status !== 'error');
|
|
1326
|
+
return {
|
|
1327
|
+
pluginVersion: PLUGIN_VERSION,
|
|
1328
|
+
upstream: info,
|
|
1329
|
+
checks,
|
|
1330
|
+
healthy,
|
|
1331
|
+
connectionTested: testConnection,
|
|
1332
|
+
};
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
/** Report the packaged upstream snapshot version. */
|
|
1336
|
+
checkoutVersion() {
|
|
1337
|
+
return this.adapter.readCheckoutVersion();
|
|
1338
|
+
}
|
|
1339
|
+
/** Prepared Python command. */
|
|
1340
|
+
python() {
|
|
1341
|
+
return this.adapter.versionInfo.python;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
//# sourceMappingURL=runtime.js.map
|