@mengruo/dsh-vision-toolkit 0.1.4 → 0.1.6-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -70
- package/README.zh.md +11 -69
- package/assets/1.mp4 +0 -0
- package/assets/skill/SKILL.md +37 -5
- package/docs/plan-per-tool-visibility.md +82 -0
- package/lib/client.js +157 -6
- package/lib/client.js.map +1 -1
- package/lib/config.js +52 -0
- package/lib/config.js.map +1 -1
- package/lib/exposure.js +35 -8
- package/lib/exposure.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/object-storage.js +141 -0
- package/lib/object-storage.js.map +1 -0
- package/lib/paths.js +9 -0
- package/lib/paths.js.map +1 -1
- package/lib/runtime.js +314 -27
- package/lib/runtime.js.map +1 -1
- package/lib/tools.js +116 -4
- package/lib/tools.js.map +1 -1
- package/lib/types/client/index.d.ts +62 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +59 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/exposure.d.ts +14 -2
- package/lib/types/exposure.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/object-storage.d.ts +54 -0
- package/lib/types/object-storage.d.ts.map +1 -0
- package/lib/types/paths.d.ts +7 -0
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +50 -0
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/tools.d.ts +22 -1
- package/lib/types/tools.d.ts.map +1 -1
- package/lib/types/upstream.d.ts +1 -0
- package/lib/types/upstream.d.ts.map +1 -1
- package/lib/types/video.d.ts +78 -0
- package/lib/types/video.d.ts.map +1 -0
- package/lib/types/web.d.ts +8 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/upstream.js +3 -0
- package/lib/upstream.js.map +1 -1
- package/lib/video.js +169 -0
- package/lib/video.js.map +1 -0
- package/lib/web.js +78 -6
- package/lib/web.js.map +1 -1
- package/package.json +4 -1
- package/src/client/index.tsx +222 -6
- package/src/config.ts +110 -0
- package/src/exposure.ts +34 -9
- package/src/index.ts +10 -5
- package/src/object-storage.ts +174 -0
- package/src/paths.ts +11 -0
- package/src/runtime.ts +332 -25
- package/src/tools.ts +144 -3
- package/src/upstream.ts +4 -0
- package/src/video.ts +222 -0
- package/src/web.ts +93 -7
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
- package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
- package/vendor/agent-vision-toolkit/bin/glance +8 -1
- package/vendor/agent-vision-toolkit/detect.py +13 -7
- package/vendor/agent-vision-toolkit/ground.py +43 -18
- package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
- package/vendor/agent-vision-toolkit/vision_client.py +84 -6
- package/assets/community-group-qr.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/logo_eapi_dark.png +0 -0
- package/assets/wechat-reward.png +0 -0
package/src/video.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local video support built on the bundled `ffprobe-static` binary instead of
|
|
3
|
+
* the pinned Python vision pipeline: metadata probing stays a plain JS
|
|
4
|
+
* subprocess, and the model-facing video request reuses the existing object
|
|
5
|
+
* storage + OpenAI-compatible (Aliyun Qwen) chat-completions transport.
|
|
6
|
+
* @module dsh-vision-toolkit/video
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
|
|
11
|
+
const require = createRequire(import.meta.url)
|
|
12
|
+
|
|
13
|
+
/** Metadata derived from one ffprobe run (path/bytes are supplied by the runtime). */
|
|
14
|
+
export interface VideoMetadata {
|
|
15
|
+
/** Container format name, e.g. "mov" or "matroska". */
|
|
16
|
+
format: string
|
|
17
|
+
/** Format duration in seconds, or null when the stream reports none. */
|
|
18
|
+
durationSeconds: number | null
|
|
19
|
+
/** Width of the first video stream, or null when the file carries no video. */
|
|
20
|
+
width: number | null
|
|
21
|
+
/** Height of the first video stream, or null when the file carries no video. */
|
|
22
|
+
height: number | null
|
|
23
|
+
/** Average frame rate of the first video stream, or null when unknown. */
|
|
24
|
+
frameRate: number | null
|
|
25
|
+
/** Codec of the first video stream, or null when the file carries no video. */
|
|
26
|
+
videoCodec: string | null
|
|
27
|
+
/** Codec of the first audio stream, or null when the file carries no audio. */
|
|
28
|
+
audioCodec: string | null
|
|
29
|
+
/** Format bit rate in bits per second, or null when unknown. */
|
|
30
|
+
bitRate: number | null
|
|
31
|
+
/** Number of video streams. */
|
|
32
|
+
videoStreamCount: number
|
|
33
|
+
/** Number of audio streams. */
|
|
34
|
+
audioStreamCount: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Full structured result for one local video-info probe. */
|
|
38
|
+
export interface VideoInfo extends VideoMetadata {
|
|
39
|
+
/** Fence-checked absolute input path. */
|
|
40
|
+
path: string
|
|
41
|
+
/** Input file size in bytes. */
|
|
42
|
+
bytes: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Input for the local video-info tool (no API call). */
|
|
46
|
+
export interface VideoInfoRequest {
|
|
47
|
+
video: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Input for the video-understanding tool (video + prompt → vision API). */
|
|
51
|
+
export interface VideoUnderstandRequest {
|
|
52
|
+
video: string
|
|
53
|
+
prompt: string
|
|
54
|
+
/** Video sampling frame rate passed to the model; default 2. */
|
|
55
|
+
fps?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Result of one video-understanding call. */
|
|
59
|
+
export interface VideoUnderstandResult {
|
|
60
|
+
/** Fence-checked absolute input path. */
|
|
61
|
+
path: string
|
|
62
|
+
/** The vision model's answer text. */
|
|
63
|
+
answer: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve the bundled ffprobe executable. An explicit `FFPROBE_BIN` override
|
|
68
|
+
* wins (used by tests and unusual installs); otherwise the platform-correct
|
|
69
|
+
* binary shipped by `ffprobe-static` is returned. `null` means no binary is
|
|
70
|
+
* available and the caller must fail loud.
|
|
71
|
+
*/
|
|
72
|
+
export function ffprobeBinaryPath(): string | null {
|
|
73
|
+
const override = process.env.FFPROBE_BIN?.trim()
|
|
74
|
+
if (override !== undefined && override.length > 0) return override
|
|
75
|
+
try {
|
|
76
|
+
const mod = require('ffprobe-static') as { path?: unknown } | undefined
|
|
77
|
+
const candidate = mod?.path
|
|
78
|
+
return typeof candidate === 'string' && candidate.length > 0 ? candidate : null
|
|
79
|
+
} catch {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** S3 object Content-Type for one video extension (browser-probing convention). */
|
|
85
|
+
export function videoMediaType(extension: string): string {
|
|
86
|
+
switch (extension) {
|
|
87
|
+
case '.mp4': return 'video/mp4'
|
|
88
|
+
case '.mov': return 'video/quicktime'
|
|
89
|
+
case '.webm': return 'video/webm'
|
|
90
|
+
case '.mkv': return 'video/x-matroska'
|
|
91
|
+
case '.avi': return 'video/x-msvideo'
|
|
92
|
+
case '.m4v': return 'video/x-m4v'
|
|
93
|
+
case '.mpeg':
|
|
94
|
+
case '.mpg': return 'video/mpeg'
|
|
95
|
+
case '.wmv': return 'video/x-ms-wmv'
|
|
96
|
+
case '.flv': return 'video/x-flv'
|
|
97
|
+
case '.ts':
|
|
98
|
+
case '.m2ts': return 'video/mp2t'
|
|
99
|
+
case '.3gp': return 'video/3gpp'
|
|
100
|
+
default: return 'application/octet-stream'
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
105
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function stringValue(value: unknown): string | null {
|
|
109
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function integerValue(value: unknown): number | null {
|
|
113
|
+
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Parse "num/den" or a plain float string into a frame rate, else null. */
|
|
117
|
+
function parseRate(raw: unknown): number | null {
|
|
118
|
+
const text = stringValue(raw)
|
|
119
|
+
if (text === null) return null
|
|
120
|
+
const slash = text.indexOf('/')
|
|
121
|
+
if (slash === -1) {
|
|
122
|
+
const value = Number(text)
|
|
123
|
+
return Number.isFinite(value) && value > 0 ? value : null
|
|
124
|
+
}
|
|
125
|
+
const numerator = Number(text.slice(0, slash))
|
|
126
|
+
const denominator = Number(text.slice(slash + 1))
|
|
127
|
+
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) return null
|
|
128
|
+
const value = numerator / denominator
|
|
129
|
+
return Number.isFinite(value) && value > 0 ? value : null
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse the JSON printed by `ffprobe -print_format json -show_format -show_streams`
|
|
134
|
+
* into a compact, model-facing metadata object. Missing or malformed fields
|
|
135
|
+
* degrade to null rather than throwing: metadata is advisory, and the probe
|
|
136
|
+
* only fails when the process itself failed or returned no JSON.
|
|
137
|
+
*/
|
|
138
|
+
export function parseFfprobeOutput(stdout: string): VideoMetadata {
|
|
139
|
+
let parsed: unknown
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(stdout)
|
|
142
|
+
} catch {
|
|
143
|
+
throw new Error('ffprobe returned invalid JSON')
|
|
144
|
+
}
|
|
145
|
+
if (!isRecord(parsed)) throw new Error('ffprobe returned an unexpected structure')
|
|
146
|
+
|
|
147
|
+
const streams = Array.isArray(parsed.streams) ? parsed.streams.filter(isRecord) : []
|
|
148
|
+
const format = isRecord(parsed.format) ? parsed.format : {}
|
|
149
|
+
|
|
150
|
+
let videoCodec: string | null = null
|
|
151
|
+
let audioCodec: string | null = null
|
|
152
|
+
let width: number | null = null
|
|
153
|
+
let height: number | null = null
|
|
154
|
+
let frameRate: number | null = null
|
|
155
|
+
let videoStreamCount = 0
|
|
156
|
+
let audioStreamCount = 0
|
|
157
|
+
for (const stream of streams) {
|
|
158
|
+
const codecType = stringValue(stream.codec_type)
|
|
159
|
+
const codecName = stringValue(stream.codec_name)
|
|
160
|
+
if (codecType === 'video') {
|
|
161
|
+
videoStreamCount += 1
|
|
162
|
+
if (videoCodec === null) {
|
|
163
|
+
videoCodec = codecName
|
|
164
|
+
width = integerValue(stream.width)
|
|
165
|
+
height = integerValue(stream.height)
|
|
166
|
+
frameRate = parseRate(stream.avg_frame_rate ?? stream.r_frame_rate)
|
|
167
|
+
}
|
|
168
|
+
} else if (codecType === 'audio') {
|
|
169
|
+
audioStreamCount += 1
|
|
170
|
+
if (audioCodec === null) audioCodec = codecName
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const durationText = stringValue(format.duration)
|
|
175
|
+
const durationSeconds = durationText === null || durationText === 'N/A'
|
|
176
|
+
? null
|
|
177
|
+
: (() => { const value = Number(durationText); return Number.isFinite(value) && value >= 0 ? value : null })()
|
|
178
|
+
|
|
179
|
+
const formatName = stringValue(format.format_name)?.split(',')[0]?.trim() ?? null
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
format: formatName ?? 'unknown',
|
|
183
|
+
durationSeconds,
|
|
184
|
+
width,
|
|
185
|
+
height,
|
|
186
|
+
frameRate,
|
|
187
|
+
videoCodec,
|
|
188
|
+
audioCodec,
|
|
189
|
+
bitRate: integerValue(typeof format.bit_rate === 'string' ? Number(format.bit_rate) : format.bit_rate),
|
|
190
|
+
videoStreamCount,
|
|
191
|
+
audioStreamCount,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Extract the answer text from one OpenAI-compatible chat-completions response
|
|
197
|
+
* body. Handles both string content and array content (text parts joined).
|
|
198
|
+
* @returns the answer text, or an empty string when the shape is unrecognized.
|
|
199
|
+
*/
|
|
200
|
+
export function extractChatAnswer(body: string): string {
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(body) as unknown
|
|
203
|
+
if (!isRecord(parsed)) return ''
|
|
204
|
+
const choices = parsed.choices
|
|
205
|
+
if (!Array.isArray(choices) || choices.length === 0) return ''
|
|
206
|
+
const first = choices[0]
|
|
207
|
+
if (!isRecord(first)) return ''
|
|
208
|
+
const message = first.message
|
|
209
|
+
if (!isRecord(message)) return ''
|
|
210
|
+
const content = message.content
|
|
211
|
+
if (typeof content === 'string') return content
|
|
212
|
+
if (Array.isArray(content)) {
|
|
213
|
+
return content
|
|
214
|
+
.filter(isRecord)
|
|
215
|
+
.map(part => (typeof part.text === 'string' ? part.text : ''))
|
|
216
|
+
.join('')
|
|
217
|
+
}
|
|
218
|
+
return ''
|
|
219
|
+
} catch {
|
|
220
|
+
return ''
|
|
221
|
+
}
|
|
222
|
+
}
|
package/src/web.ts
CHANGED
|
@@ -75,6 +75,12 @@ export interface VisionToolkitSettingsSnapshot {
|
|
|
75
75
|
source?: string
|
|
76
76
|
writable: boolean
|
|
77
77
|
}>
|
|
78
|
+
/** Object-storage credential state; `ref` is empty when object storage is unset. */
|
|
79
|
+
objectStorageCredential: {
|
|
80
|
+
ref: string
|
|
81
|
+
configured: boolean
|
|
82
|
+
writable: boolean
|
|
83
|
+
}
|
|
78
84
|
runtime: RuntimeManagerStatus
|
|
79
85
|
release: {
|
|
80
86
|
pluginVersion: string
|
|
@@ -121,7 +127,17 @@ interface ApplyUpdateRequest {
|
|
|
121
127
|
expectedVersion: string
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
|
|
130
|
+
interface StorageTestRequest {
|
|
131
|
+
action: 'test-storage'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface VideoTestRequest {
|
|
135
|
+
action: 'test-video'
|
|
136
|
+
/** 0-based provider index; when absent, the primary provider is tested. */
|
|
137
|
+
providerIndex?: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type SettingsRequest = SaveRequest | HealthRequest | CredentialRequest | DeleteCredentialRequest | CheckUpdateRequest | ApplyUpdateRequest | StorageTestRequest | VideoTestRequest
|
|
125
141
|
|
|
126
142
|
interface JsonError {
|
|
127
143
|
ok: false
|
|
@@ -261,6 +277,17 @@ function parseRequest(value: unknown): SettingsRequest {
|
|
|
261
277
|
}
|
|
262
278
|
return { action: 'apply-update', expectedVersion: value.expectedVersion.trim() }
|
|
263
279
|
}
|
|
280
|
+
if (value.action === 'test-storage') return { action: 'test-storage' }
|
|
281
|
+
if (value.action === 'test-video') {
|
|
282
|
+
const providerIndex = value.providerIndex
|
|
283
|
+
if (providerIndex !== undefined && (!Number.isSafeInteger(providerIndex) || (providerIndex as number) < 0)) {
|
|
284
|
+
throw new TypeError('test-video.providerIndex must be a non-negative integer')
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
action: 'test-video',
|
|
288
|
+
...(providerIndex === undefined ? {} : { providerIndex: providerIndex as number }),
|
|
289
|
+
}
|
|
290
|
+
}
|
|
264
291
|
throw new TypeError(`unsupported action: ${value.action}`)
|
|
265
292
|
}
|
|
266
293
|
|
|
@@ -315,6 +342,14 @@ export class VisionToolkitWebBackend {
|
|
|
315
342
|
writable: info.writable,
|
|
316
343
|
}
|
|
317
344
|
}))
|
|
345
|
+
const objectStorageRef = resolved.objectStorage.credential === undefined ? '' : String(resolved.objectStorage.credential)
|
|
346
|
+
const objectStorageCredential = objectStorageRef === ''
|
|
347
|
+
? { ref: '', configured: false, writable: this.ctx.settings.writable }
|
|
348
|
+
: await this.ctx.credentials.describe(credentialRef(objectStorageRef)).then(info => ({
|
|
349
|
+
ref: objectStorageRef,
|
|
350
|
+
configured: info.configured,
|
|
351
|
+
writable: info.writable,
|
|
352
|
+
}))
|
|
318
353
|
const update = await this.updater.capability()
|
|
319
354
|
return {
|
|
320
355
|
schemaVersion: 1,
|
|
@@ -333,6 +368,7 @@ export class VisionToolkitWebBackend {
|
|
|
333
368
|
writable: credential.writable,
|
|
334
369
|
},
|
|
335
370
|
credentials,
|
|
371
|
+
objectStorageCredential,
|
|
336
372
|
runtime: this.manager.status(),
|
|
337
373
|
release: {
|
|
338
374
|
pluginVersion: PLUGIN_VERSION,
|
|
@@ -380,10 +416,18 @@ export class VisionToolkitWebBackend {
|
|
|
380
416
|
)
|
|
381
417
|
}
|
|
382
418
|
const resolved = resolveConfig(descriptor.value as VisionToolkitConfig)
|
|
383
|
-
const
|
|
419
|
+
const ref = String(request.ref)
|
|
420
|
+
if (resolved.objectStorage.credential !== undefined && String(resolved.objectStorage.credential) === ref) {
|
|
421
|
+
if (!request.value.includes(':')) {
|
|
422
|
+
throw new Error('object storage credential must be "accessKeyId:secretAccessKey"')
|
|
423
|
+
}
|
|
424
|
+
await this.ctx.credentials.set(request.ref, request.value)
|
|
425
|
+
return this.snapshot()
|
|
426
|
+
}
|
|
427
|
+
const provider = resolved.providers.find(entry => String(entry.credential) === ref)
|
|
384
428
|
if (provider === undefined) {
|
|
385
429
|
throw new CredentialReferenceConflictError(
|
|
386
|
-
`credential reference "${
|
|
430
|
+
`credential reference "${ref}" does not match any configured vision provider; reload Settings and try again`,
|
|
387
431
|
)
|
|
388
432
|
}
|
|
389
433
|
if (isBuiltInFreeVisionProvider(provider)) {
|
|
@@ -426,6 +470,38 @@ export class VisionToolkitWebBackend {
|
|
|
426
470
|
}
|
|
427
471
|
}
|
|
428
472
|
|
|
473
|
+
private async testStorage(): Promise<{ detail: string }> {
|
|
474
|
+
if (!this.manager.ready) throw new Error('runtime is not ready; fix Settings and save a valid configuration first')
|
|
475
|
+
return this.manager.current().testObjectStorage()
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async testVideo(request: VideoTestRequest, req: IncomingMessage): Promise<{ detail: string }> {
|
|
479
|
+
if (!this.manager.ready) throw new Error('runtime is not ready; fix Settings and save a valid configuration first')
|
|
480
|
+
const controller = new AbortController()
|
|
481
|
+
const abort = (): void => { controller.abort() }
|
|
482
|
+
req.once('aborted', abort)
|
|
483
|
+
req.socket.once('close', abort)
|
|
484
|
+
try {
|
|
485
|
+
const runtime = this.manager.current()
|
|
486
|
+
let provider: ResolvedProvider | undefined
|
|
487
|
+
if (request.providerIndex !== undefined) {
|
|
488
|
+
const resolved = resolveConfig(descriptorOf(this.ctx).value as VisionToolkitConfig)
|
|
489
|
+
provider = resolved.providers[request.providerIndex]
|
|
490
|
+
if (provider === undefined) {
|
|
491
|
+
throw new Error(`provider index ${request.providerIndex} is out of range`)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return await runtime.testVideoCall({
|
|
495
|
+
signal: controller.signal,
|
|
496
|
+
workspace: runtime.upstreamVersion.runtimeHome,
|
|
497
|
+
sessionId: 'vision-toolkit-settings',
|
|
498
|
+
}, provider)
|
|
499
|
+
} finally {
|
|
500
|
+
req.off('aborted', abort)
|
|
501
|
+
req.socket.off('close', abort)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
429
505
|
/** Handle the exact Settings route. */
|
|
430
506
|
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
431
507
|
if (req.method === 'GET') {
|
|
@@ -458,6 +534,12 @@ export class VisionToolkitWebBackend {
|
|
|
458
534
|
case 'health':
|
|
459
535
|
responseJson(res, 200, { ok: true, value: await this.health(parsed, req) })
|
|
460
536
|
break
|
|
537
|
+
case 'test-storage':
|
|
538
|
+
responseJson(res, 200, { ok: true, value: await this.testStorage() })
|
|
539
|
+
break
|
|
540
|
+
case 'test-video':
|
|
541
|
+
responseJson(res, 200, { ok: true, value: await this.testVideo(parsed, req) })
|
|
542
|
+
break
|
|
461
543
|
case 'save':
|
|
462
544
|
responseJson(res, 200, { ok: true, value: await this.save(parsed) })
|
|
463
545
|
break
|
|
@@ -486,14 +568,18 @@ export class VisionToolkitWebBackend {
|
|
|
486
568
|
? error.code
|
|
487
569
|
: parsed.action === 'health'
|
|
488
570
|
? 'health-failed'
|
|
489
|
-
: parsed.action === '
|
|
490
|
-
? '
|
|
491
|
-
: '
|
|
571
|
+
: parsed.action === 'test-storage'
|
|
572
|
+
? 'storage-test-failed'
|
|
573
|
+
: parsed.action === 'test-video'
|
|
574
|
+
? 'video-test-failed'
|
|
575
|
+
: parsed.action === 'credential'
|
|
576
|
+
? 'credential-rejected'
|
|
577
|
+
: 'settings-rejected'
|
|
492
578
|
const updateConflict = updateError && ['update-in-progress', 'update-stale', 'update-unavailable', 'already-current'].includes(error.code)
|
|
493
579
|
const updateGateway = updateError && error.code === 'update-check-failed'
|
|
494
580
|
const status = settingsConflict || credentialConflict || updateConflict
|
|
495
581
|
? 409
|
|
496
|
-
: parsed.action === 'health'
|
|
582
|
+
: parsed.action === 'health' || parsed.action === 'test-storage' || parsed.action === 'test-video'
|
|
497
583
|
? 503
|
|
498
584
|
: updateGateway
|
|
499
585
|
? 502
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"repository": "https://github.com/Anionex/agent-vision-toolkit",
|
|
4
4
|
"version": "v0.1.0+snapshot.bc9803d",
|
|
5
5
|
"commit": "bc9803d7d6300c864d17460ecbb33540b26638e0",
|
|
6
|
-
"contentSha256": "
|
|
6
|
+
"contentSha256": "09bedf007e1469a28981227def5aa33e03667ea21fe2972937f0359898e2928e",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "CHANGELOG.md",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
},
|
|
33
33
|
{
|
|
34
34
|
"path": "bin/glance",
|
|
35
|
-
"bytes":
|
|
36
|
-
"sha256": "
|
|
35
|
+
"bytes": 3941,
|
|
36
|
+
"sha256": "7552bef0ba396162f64e8fc4c4a53404dfe2d3149ca3d1c5c8bd717facf82af6"
|
|
37
37
|
},
|
|
38
38
|
{
|
|
39
39
|
"path": "bin/ground",
|
|
@@ -47,13 +47,13 @@
|
|
|
47
47
|
},
|
|
48
48
|
{
|
|
49
49
|
"path": "detect.py",
|
|
50
|
-
"bytes":
|
|
51
|
-
"sha256": "
|
|
50
|
+
"bytes": 2532,
|
|
51
|
+
"sha256": "cac2135f9d835215f3ba90bfd70fd84dd02b0e88299f2b60e109e006b4f3a4d9"
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
"path": "ground.py",
|
|
55
|
-
"bytes":
|
|
56
|
-
"sha256": "
|
|
55
|
+
"bytes": 11289,
|
|
56
|
+
"sha256": "6d3175d3f5a7d561d55b2a967c5ca3d49940e80014de658ed9a3e5d45a233a2f"
|
|
57
57
|
},
|
|
58
58
|
{
|
|
59
59
|
"path": "skills/vision-tools/scripts/dominant_colors.py",
|
|
@@ -82,13 +82,13 @@
|
|
|
82
82
|
},
|
|
83
83
|
{
|
|
84
84
|
"path": "tests/test_vision_client.py",
|
|
85
|
-
"bytes":
|
|
86
|
-
"sha256": "
|
|
85
|
+
"bytes": 24882,
|
|
86
|
+
"sha256": "87e39429766b38caed6a4ccb1ac8b193094c69858a0f7541940a005f66421e45"
|
|
87
87
|
},
|
|
88
88
|
{
|
|
89
89
|
"path": "vision_client.py",
|
|
90
|
-
"bytes":
|
|
91
|
-
"sha256": "
|
|
90
|
+
"bytes": 15053,
|
|
91
|
+
"sha256": "6800843b99df500a476b1dd6ff5a4c05aa8122ed990f6f91b16a11ee07bdebe6"
|
|
92
92
|
}
|
|
93
93
|
]
|
|
94
94
|
}
|
|
Binary file
|
|
Binary file
|
|
@@ -40,6 +40,13 @@ def region_data_url(path, region):
|
|
|
40
40
|
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def image_url(path):
|
|
44
|
+
"""Pass http(s) URLs through unchanged; encode local files as data URLs."""
|
|
45
|
+
if path.startswith(("http://", "https://")):
|
|
46
|
+
return path
|
|
47
|
+
return image_path_to_data_url(path)
|
|
48
|
+
|
|
49
|
+
|
|
43
50
|
def build_prompt(args, count):
|
|
44
51
|
if args.ocr is not None:
|
|
45
52
|
extra = f" Additional requirements: {args.ocr}" if args.ocr else ""
|
|
@@ -77,7 +84,7 @@ def main():
|
|
|
77
84
|
if args.region and len(args.images) > 1:
|
|
78
85
|
raise VisionError("--region works with exactly one image")
|
|
79
86
|
urls = ([region_data_url(args.images[0], args.region)] if args.region
|
|
80
|
-
else [
|
|
87
|
+
else [image_url(path) for path in args.images])
|
|
81
88
|
answer = describe_image(
|
|
82
89
|
urls,
|
|
83
90
|
build_prompt(args, len(urls)),
|
|
@@ -8,7 +8,7 @@ try:
|
|
|
8
8
|
except ImportError:
|
|
9
9
|
Image = None
|
|
10
10
|
|
|
11
|
-
from ground import GroundError, _position, locate
|
|
11
|
+
from ground import GroundError, _parse_size, _position, locate
|
|
12
12
|
from vision_client import VisionError
|
|
13
13
|
|
|
14
14
|
DEFAULT_CATEGORY = ("UI element (buttons, links, inputs, icons, labels, "
|
|
@@ -38,18 +38,24 @@ def main() -> None:
|
|
|
38
38
|
prog="detect",
|
|
39
39
|
description="Inventory the elements in an image (or a region) with pixel bounding boxes",
|
|
40
40
|
)
|
|
41
|
-
parser.add_argument("image",
|
|
41
|
+
parser.add_argument("image", help="path or http(s) URL to the image")
|
|
42
42
|
parser.add_argument("category", nargs="?",
|
|
43
43
|
help='restrict to a category, e.g. "buttons" or "icons" (default: all UI elements)')
|
|
44
44
|
parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
45
45
|
help="inventory only this pixel box; output stays in original-image coordinates")
|
|
46
|
+
parser.add_argument("--size", metavar="WxH",
|
|
47
|
+
help="analyzed image dimensions when the image is an http(s) URL")
|
|
46
48
|
args = parser.parse_args()
|
|
47
49
|
try:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
size = _parse_size(args.size) if args.size else None
|
|
51
|
+
matches = locate(args.image, build_target(args.category), region=args.region, size=size)
|
|
52
|
+
if size is not None:
|
|
53
|
+
width, height = size
|
|
54
|
+
else:
|
|
55
|
+
if Image is None:
|
|
56
|
+
raise GroundError("detect requires Pillow; install the optional dependency pillow first")
|
|
57
|
+
with Image.open(Path(args.image).expanduser()) as image:
|
|
58
|
+
width, height = image.size
|
|
53
59
|
except (GroundError, VisionError) as exc:
|
|
54
60
|
parser.exit(1, f"detect: {exc}\n")
|
|
55
61
|
for line in format_inventory(matches, width, height):
|
|
@@ -184,23 +184,32 @@ def _parse_region(region: str, width: int, height: int) -> tuple[int, int, int,
|
|
|
184
184
|
return box
|
|
185
185
|
|
|
186
186
|
|
|
187
|
-
def locate(image_path:
|
|
187
|
+
def locate(image_path: str, target: str, region: str | None = None,
|
|
188
|
+
size: tuple[int, int] | None = None) -> list[Match]:
|
|
188
189
|
if Image is None:
|
|
189
190
|
raise GroundError("ground requires Pillow; install the optional dependency pillow first")
|
|
190
191
|
load_default_env()
|
|
191
192
|
box = None
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
193
|
+
if size is not None:
|
|
194
|
+
# URL transfer: the image was uploaded to object storage and this caller
|
|
195
|
+
# supplies the analyzed dimensions, so no local file access is needed.
|
|
196
|
+
width, height = size
|
|
197
|
+
url = image_path if image_path.startswith(("http://", "https://")) else image_path_to_data_url(image_path)
|
|
198
|
+
else:
|
|
199
|
+
local = Path(image_path).expanduser()
|
|
200
|
+
try:
|
|
201
|
+
with Image.open(local) as image:
|
|
202
|
+
width, height = image.size
|
|
203
|
+
if region:
|
|
204
|
+
box = _parse_region(region, width, height)
|
|
205
|
+
buffer = io.BytesIO()
|
|
206
|
+
image.crop(box).save(buffer, format="PNG")
|
|
207
|
+
url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
|
|
208
|
+
except (OSError, ValueError) as exc:
|
|
209
|
+
raise GroundError(f"Cannot read image: {image_path}") from exc
|
|
210
|
+
if box is None:
|
|
211
|
+
url = image_path_to_data_url(local)
|
|
202
212
|
if box is None:
|
|
203
|
-
url = image_path_to_data_url(image_path)
|
|
204
213
|
width_used, height_used = width, height
|
|
205
214
|
else:
|
|
206
215
|
width_used, height_used = box[2] - box[0], box[3] - box[1]
|
|
@@ -216,6 +225,16 @@ def locate(image_path: Path, target: str, region: str | None = None) -> list[Mat
|
|
|
216
225
|
m.bbox[2] + box[0], m.bbox[3] + box[1])) for m in matches]
|
|
217
226
|
|
|
218
227
|
|
|
228
|
+
def _parse_size(value: str) -> tuple[int, int]:
|
|
229
|
+
try:
|
|
230
|
+
width, height = (int(part) for part in value.lower().split("x"))
|
|
231
|
+
except (ValueError, AttributeError):
|
|
232
|
+
raise GroundError("--size expects WIDTHxHEIGHT (e.g. 1024x768)") from None
|
|
233
|
+
if width <= 0 or height <= 0:
|
|
234
|
+
raise GroundError("--size expects positive WIDTHxHEIGHT")
|
|
235
|
+
return width, height
|
|
236
|
+
|
|
237
|
+
|
|
219
238
|
def _position(box: tuple[int, int, int, int], width: int, height: int) -> str:
|
|
220
239
|
x1, y1, x2, y2 = box
|
|
221
240
|
x = (x1 + x2) / 2
|
|
@@ -246,17 +265,23 @@ def main() -> None:
|
|
|
246
265
|
prog="ground",
|
|
247
266
|
description="Locate targets in an image with natural language and output pixel coordinates",
|
|
248
267
|
)
|
|
249
|
-
parser.add_argument("image",
|
|
268
|
+
parser.add_argument("image", help="path or http(s) URL to the image")
|
|
250
269
|
parser.add_argument("target", help="target object or region to locate")
|
|
251
270
|
parser.add_argument("--region", metavar="X1,Y1,X2,Y2",
|
|
252
271
|
help="search only this pixel box; output stays in original-image coordinates")
|
|
272
|
+
parser.add_argument("--size", metavar="WxH",
|
|
273
|
+
help="analyzed image dimensions when the image is an http(s) URL")
|
|
253
274
|
args = parser.parse_args()
|
|
254
275
|
try:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
276
|
+
size = _parse_size(args.size) if args.size else None
|
|
277
|
+
matches = locate(args.image, args.target, region=args.region, size=size)
|
|
278
|
+
if size is not None:
|
|
279
|
+
width, height = size
|
|
280
|
+
else:
|
|
281
|
+
if Image is None:
|
|
282
|
+
raise GroundError("ground requires Pillow; install the optional dependency pillow first")
|
|
283
|
+
with Image.open(Path(args.image).expanduser()) as image:
|
|
284
|
+
width, height = image.size
|
|
260
285
|
except (GroundError, VisionError) as exc:
|
|
261
286
|
parser.exit(1, f"ground: {exc}\n")
|
|
262
287
|
for line in format_matches(matches, width, height):
|