@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.
Files changed (74) hide show
  1. package/README.md +11 -70
  2. package/README.zh.md +11 -69
  3. package/assets/1.mp4 +0 -0
  4. package/assets/skill/SKILL.md +37 -5
  5. package/docs/plan-per-tool-visibility.md +82 -0
  6. package/lib/client.js +157 -6
  7. package/lib/client.js.map +1 -1
  8. package/lib/config.js +52 -0
  9. package/lib/config.js.map +1 -1
  10. package/lib/exposure.js +35 -8
  11. package/lib/exposure.js.map +1 -1
  12. package/lib/index.js +1 -1
  13. package/lib/index.js.map +1 -1
  14. package/lib/object-storage.js +141 -0
  15. package/lib/object-storage.js.map +1 -0
  16. package/lib/paths.js +9 -0
  17. package/lib/paths.js.map +1 -1
  18. package/lib/runtime.js +314 -27
  19. package/lib/runtime.js.map +1 -1
  20. package/lib/tools.js +116 -4
  21. package/lib/tools.js.map +1 -1
  22. package/lib/types/client/index.d.ts +62 -1
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/config.d.ts +59 -0
  25. package/lib/types/config.d.ts.map +1 -1
  26. package/lib/types/exposure.d.ts +14 -2
  27. package/lib/types/exposure.d.ts.map +1 -1
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/object-storage.d.ts +54 -0
  30. package/lib/types/object-storage.d.ts.map +1 -0
  31. package/lib/types/paths.d.ts +7 -0
  32. package/lib/types/paths.d.ts.map +1 -1
  33. package/lib/types/runtime.d.ts +50 -0
  34. package/lib/types/runtime.d.ts.map +1 -1
  35. package/lib/types/tools.d.ts +22 -1
  36. package/lib/types/tools.d.ts.map +1 -1
  37. package/lib/types/upstream.d.ts +1 -0
  38. package/lib/types/upstream.d.ts.map +1 -1
  39. package/lib/types/video.d.ts +78 -0
  40. package/lib/types/video.d.ts.map +1 -0
  41. package/lib/types/web.d.ts +8 -0
  42. package/lib/types/web.d.ts.map +1 -1
  43. package/lib/upstream.js +3 -0
  44. package/lib/upstream.js.map +1 -1
  45. package/lib/video.js +169 -0
  46. package/lib/video.js.map +1 -0
  47. package/lib/web.js +78 -6
  48. package/lib/web.js.map +1 -1
  49. package/package.json +4 -1
  50. package/src/client/index.tsx +222 -6
  51. package/src/config.ts +110 -0
  52. package/src/exposure.ts +34 -9
  53. package/src/index.ts +10 -5
  54. package/src/object-storage.ts +174 -0
  55. package/src/paths.ts +11 -0
  56. package/src/runtime.ts +332 -25
  57. package/src/tools.ts +144 -3
  58. package/src/upstream.ts +4 -0
  59. package/src/video.ts +222 -0
  60. package/src/web.ts +93 -7
  61. package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +11 -11
  62. package/vendor/agent-vision-toolkit/__pycache__/detect.cpython-314.pyc +0 -0
  63. package/vendor/agent-vision-toolkit/__pycache__/ground.cpython-314.pyc +0 -0
  64. package/vendor/agent-vision-toolkit/__pycache__/vision_client.cpython-314.pyc +0 -0
  65. package/vendor/agent-vision-toolkit/bin/__pycache__/glancecpython-314.pyc +0 -0
  66. package/vendor/agent-vision-toolkit/bin/glance +8 -1
  67. package/vendor/agent-vision-toolkit/detect.py +13 -7
  68. package/vendor/agent-vision-toolkit/ground.py +43 -18
  69. package/vendor/agent-vision-toolkit/tests/test_vision_client.py +88 -0
  70. package/vendor/agent-vision-toolkit/vision_client.py +84 -6
  71. package/assets/community-group-qr.png +0 -0
  72. package/assets/logo_aihubmix.png +0 -0
  73. package/assets/logo_eapi_dark.png +0 -0
  74. 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
- type SettingsRequest = SaveRequest | HealthRequest | CredentialRequest | DeleteCredentialRequest | CheckUpdateRequest | ApplyUpdateRequest
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 provider = resolved.providers.find(entry => String(entry.credential) === String(request.ref))
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 "${String(request.ref)}" does not match any configured vision provider; reload Settings and try again`,
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 === 'credential'
490
- ? 'credential-rejected'
491
- : 'settings-rejected'
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": "0eaa22a0d1d0dd6d6523a0ddd3fa8197246005042bc164a703fb05102b287a38",
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": 3742,
36
- "sha256": "fa4ba52e8e180475b948daec817151d893d957f973f7d9df1b8bbf201051cefc"
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": 2218,
51
- "sha256": "48a7070084f5b23b1477fa9a690ef1e679da8a03228e64a1ac583de633040bfc"
50
+ "bytes": 2532,
51
+ "sha256": "cac2135f9d835215f3ba90bfd70fd84dd02b0e88299f2b60e109e006b4f3a4d9"
52
52
  },
53
53
  {
54
54
  "path": "ground.py",
55
- "bytes": 10117,
56
- "sha256": "845e56dbdf92f2c79495170f5d215de49985b3099012b4d398231d3c78bd0090"
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": 20481,
86
- "sha256": "b019f0da28b7567226292044f791059ee34d7f0088bd9ceb328cfa1bab250a48"
85
+ "bytes": 24882,
86
+ "sha256": "87e39429766b38caed6a4ccb1ac8b193094c69858a0f7541940a005f66421e45"
87
87
  },
88
88
  {
89
89
  "path": "vision_client.py",
90
- "bytes": 11838,
91
- "sha256": "c6a48048c864e99513faf90ba17c4ac0d9462bb718683682ed8eda12c0e2cfe0"
90
+ "bytes": 15053,
91
+ "sha256": "6800843b99df500a476b1dd6ff5a4c05aa8122ed990f6f91b16a11ee07bdebe6"
92
92
  }
93
93
  ]
94
94
  }
@@ -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 [image_path_to_data_url(path) for path in args.images])
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", type=Path, help="path to the 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
- matches = locate(args.image.expanduser(), build_target(args.category), region=args.region)
49
- if Image is None:
50
- raise GroundError("detect requires Pillow; install the optional dependency pillow first")
51
- with Image.open(args.image.expanduser()) as image:
52
- width, height = image.size
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: Path, target: str, region: str | None = None) -> list[Match]:
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
- try:
193
- with Image.open(image_path) as image:
194
- width, height = image.size
195
- if region:
196
- box = _parse_region(region, width, height)
197
- buffer = io.BytesIO()
198
- image.crop(box).save(buffer, format="PNG")
199
- url = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
200
- except (OSError, ValueError) as exc:
201
- raise GroundError(f"Cannot read image: {image_path}") from exc
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", type=Path, help="path to the 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
- matches = locate(args.image.expanduser(), args.target, region=args.region)
256
- if Image is None:
257
- raise GroundError("ground requires Pillow; install the optional dependency pillow first")
258
- with Image.open(args.image.expanduser()) as image:
259
- width, height = image.size
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):