@mengruo/dsh-vision-toolkit 0.1.5 → 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/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 +53 -3
- package/lib/client.js.map +1 -1
- package/lib/config.js +17 -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/paths.js +9 -0
- package/lib/paths.js.map +1 -1
- package/lib/runtime.js +175 -1
- 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 +24 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +26 -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/paths.d.ts +7 -0
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +38 -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/video.d.ts +78 -0
- package/lib/types/video.d.ts.map +1 -0
- package/lib/types/web.d.ts +1 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/video.js +169 -0
- package/lib/video.js.map +1 -0
- package/lib/web.js +47 -4
- package/lib/web.js.map +1 -1
- package/package.json +2 -1
- package/src/client/index.tsx +72 -1
- package/src/config.ts +43 -0
- package/src/exposure.ts +34 -9
- package/src/index.ts +10 -5
- package/src/paths.ts +11 -0
- package/src/runtime.ts +196 -0
- package/src/tools.ts +144 -3
- package/src/video.ts +222 -0
- package/src/web.ts +53 -5
package/src/runtime.ts
CHANGED
|
@@ -29,11 +29,23 @@ import {
|
|
|
29
29
|
isWithin,
|
|
30
30
|
resolveHtmlFile,
|
|
31
31
|
resolveInputFile,
|
|
32
|
+
resolveInputVideo,
|
|
32
33
|
resolveOutputDirectory,
|
|
33
34
|
resolveOutputFile,
|
|
34
35
|
seedStagedDirectory,
|
|
35
36
|
type PathPolicy,
|
|
36
37
|
} from './paths.ts'
|
|
38
|
+
import {
|
|
39
|
+
extractChatAnswer,
|
|
40
|
+
ffprobeBinaryPath,
|
|
41
|
+
parseFfprobeOutput,
|
|
42
|
+
videoMediaType,
|
|
43
|
+
type VideoInfo,
|
|
44
|
+
type VideoInfoRequest,
|
|
45
|
+
type VideoMetadata,
|
|
46
|
+
type VideoUnderstandRequest,
|
|
47
|
+
type VideoUnderstandResult,
|
|
48
|
+
} from './video.ts'
|
|
37
49
|
import {
|
|
38
50
|
parseCropOutput,
|
|
39
51
|
parseDominantColorsOutput,
|
|
@@ -56,6 +68,8 @@ import { PLUGIN_VERSION } from './version.ts'
|
|
|
56
68
|
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
|
|
57
69
|
const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-test.png', import.meta.url))
|
|
58
70
|
const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.'
|
|
71
|
+
const VISION_MODEL_TEST_VIDEO = fileURLToPath(new URL('../assets/1.mp4', import.meta.url))
|
|
72
|
+
const VISION_MODEL_TEST_VIDEO_PROMPT = '这个视频内容是什么?'
|
|
59
73
|
|
|
60
74
|
/** Bump when the Pillow compression ladder changes so stale cache entries are ignored. */
|
|
61
75
|
const COMPRESSED_IMAGE_CACHE_VERSION = 'v2'
|
|
@@ -813,6 +827,17 @@ export class VisionToolkitRuntime {
|
|
|
813
827
|
return this.config.storageDir
|
|
814
828
|
}
|
|
815
829
|
|
|
830
|
+
/**
|
|
831
|
+
* Whether at least one enabled OpenAI-compatible provider has video support
|
|
832
|
+
* turned on. A capability probe only: tool visibility is governed by the
|
|
833
|
+
* Settings tool-visibility video bucket, not by this flag. `videoUnderstand`
|
|
834
|
+
* re-checks it at call time and reports "video understanding unavailable"
|
|
835
|
+
* when it is false.
|
|
836
|
+
*/
|
|
837
|
+
get videoSupportEnabled(): boolean {
|
|
838
|
+
return this.videoProvider() !== undefined
|
|
839
|
+
}
|
|
840
|
+
|
|
816
841
|
/** Stable identity for persisted image descriptions produced by this runtime. */
|
|
817
842
|
get evidenceFingerprint(): string {
|
|
818
843
|
return evidenceRuntimeFingerprint(this.config, undefined, process.env.VISION_SSL_VERIFY?.trim())
|
|
@@ -970,6 +995,11 @@ export class VisionToolkitRuntime {
|
|
|
970
995
|
return this.config.providers.find(provider => provider.enabled) ?? this.config.providers[0]!
|
|
971
996
|
}
|
|
972
997
|
|
|
998
|
+
/** Highest-priority enabled OpenAI provider with video support enabled. */
|
|
999
|
+
private videoProvider(): ResolvedProvider | undefined {
|
|
1000
|
+
return this.config.providers.find(provider => provider.enabled && provider.videoSupport && provider.protocol === 'openai')
|
|
1001
|
+
}
|
|
1002
|
+
|
|
973
1003
|
/** Build the upstream environment for one resolved provider. */
|
|
974
1004
|
private providerEnv(provider: ResolvedProvider, resolved: ResolvedCredential): UpstreamEnvironment {
|
|
975
1005
|
const sslVerify = process.env.VISION_SSL_VERIFY?.trim()
|
|
@@ -999,6 +1029,7 @@ export class VisionToolkitRuntime {
|
|
|
999
1029
|
userAgent: provider.userAgent,
|
|
1000
1030
|
stream: provider.stream,
|
|
1001
1031
|
uploadViaUrl: provider.uploadViaUrl,
|
|
1032
|
+
videoSupport: provider.videoSupport,
|
|
1002
1033
|
})
|
|
1003
1034
|
? { value: BUILT_IN_FREE_VISION_KEY, source: 'built-in' }
|
|
1004
1035
|
: await this.ctx.credentials.resolve(provider.credential)
|
|
@@ -1346,6 +1377,171 @@ export class VisionToolkitRuntime {
|
|
|
1346
1377
|
return client.test()
|
|
1347
1378
|
}
|
|
1348
1379
|
|
|
1380
|
+
/**
|
|
1381
|
+
* Settings "test video call" probe: upload the bundled diagnostic video to
|
|
1382
|
+
* object storage and send one OpenAI-compatible (Aliyun Qwen) video request.
|
|
1383
|
+
* The wire content uses the Qwen video block shape (`video_url` + `fps`) plus
|
|
1384
|
+
* the fixed test question. The object is deleted afterwards, best-effort.
|
|
1385
|
+
*/
|
|
1386
|
+
async testVideoCall(options: ToolCallOptions, provider?: ResolvedProvider): Promise<{ detail: string }> {
|
|
1387
|
+
return this.runOperation('vision_toolkit_video_test', options, async (operation) => {
|
|
1388
|
+
const target = provider ?? this.primaryProvider
|
|
1389
|
+
if (target.protocol !== 'openai') {
|
|
1390
|
+
throw new VisionToolkitError('config', 'video support is only available for OpenAI-compatible providers')
|
|
1391
|
+
}
|
|
1392
|
+
const entry = await this.resolveProviderEnv(target)
|
|
1393
|
+
if (entry === undefined) {
|
|
1394
|
+
throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`)
|
|
1395
|
+
}
|
|
1396
|
+
const client = await this.resolveObjectStorageClient()
|
|
1397
|
+
if (client === undefined) {
|
|
1398
|
+
throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
|
|
1399
|
+
}
|
|
1400
|
+
const uploaded = await client.uploadImage(VISION_MODEL_TEST_VIDEO, 'video/mp4')
|
|
1401
|
+
try {
|
|
1402
|
+
const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, VISION_MODEL_TEST_VIDEO_PROMPT, 2, operation)
|
|
1403
|
+
const snippet = answer.trim().slice(0, 160)
|
|
1404
|
+
return {
|
|
1405
|
+
detail: snippet.length === 0
|
|
1406
|
+
? `Video call completed against ${target.model}`
|
|
1407
|
+
: `Video call completed against ${target.model}: ${snippet}`,
|
|
1408
|
+
}
|
|
1409
|
+
} finally {
|
|
1410
|
+
await client.deleteObject(uploaded.key)
|
|
1411
|
+
}
|
|
1412
|
+
})
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** Run the bundled ffprobe binary once and parse its JSON metadata. */
|
|
1416
|
+
private async runFfprobe(videoPath: string, operation: OperationContext): Promise<VideoMetadata> {
|
|
1417
|
+
const binary = ffprobeBinaryPath()
|
|
1418
|
+
if (binary === null) {
|
|
1419
|
+
throw new VisionToolkitError('runtime', 'ffprobe is unavailable; the ffprobe-static package must be installed alongside the plugin')
|
|
1420
|
+
}
|
|
1421
|
+
const handle = this.ctx.subprocess.spawn({
|
|
1422
|
+
argv: [binary, '-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', videoPath],
|
|
1423
|
+
cwd: process.cwd(),
|
|
1424
|
+
stdio: {
|
|
1425
|
+
stdin: 'ignore',
|
|
1426
|
+
stdout: { maxBytes: 512 * 1024 },
|
|
1427
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
1428
|
+
},
|
|
1429
|
+
graceMs: 2000,
|
|
1430
|
+
signal: operation.signal,
|
|
1431
|
+
})
|
|
1432
|
+
const outcome = await handle.done
|
|
1433
|
+
const stdout = handle.collected.stdout?.readFrom(0)
|
|
1434
|
+
const stderr = handle.collected.stderr?.readFrom(0)
|
|
1435
|
+
if (outcome.exitCode !== 0) {
|
|
1436
|
+
throw new VisionToolkitError('input', `cannot read video metadata: ${(stderr?.text ?? '').trim() || 'ffprobe failed'}`)
|
|
1437
|
+
}
|
|
1438
|
+
if (stdout?.lossy === true) {
|
|
1439
|
+
throw new VisionToolkitError('output', 'video metadata output exceeded the capture limit')
|
|
1440
|
+
}
|
|
1441
|
+
try {
|
|
1442
|
+
return parseFfprobeOutput(stdout?.text ?? '')
|
|
1443
|
+
} catch (error) {
|
|
1444
|
+
throw new VisionToolkitError('output', 'video metadata output is invalid', { cause: error })
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* One OpenAI-compatible (Aliyun Qwen) video chat-completions request. The
|
|
1450
|
+
* content array carries the video_url block (with `fps`) plus the prompt text.
|
|
1451
|
+
*/
|
|
1452
|
+
private async requestVideoAnswer(
|
|
1453
|
+
target: ResolvedProvider,
|
|
1454
|
+
apiKey: string,
|
|
1455
|
+
videoUrl: string,
|
|
1456
|
+
prompt: string,
|
|
1457
|
+
fps: number,
|
|
1458
|
+
operation: OperationContext,
|
|
1459
|
+
): Promise<string> {
|
|
1460
|
+
operation.metrics.usedVisionService = true
|
|
1461
|
+
const started = Date.now()
|
|
1462
|
+
const response = await fetch(`${target.baseUrl}/chat/completions`, {
|
|
1463
|
+
method: 'POST',
|
|
1464
|
+
headers: {
|
|
1465
|
+
'Content-Type': 'application/json',
|
|
1466
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1467
|
+
'User-Agent': target.userAgent,
|
|
1468
|
+
},
|
|
1469
|
+
body: JSON.stringify({
|
|
1470
|
+
model: target.model,
|
|
1471
|
+
messages: [{
|
|
1472
|
+
role: 'user',
|
|
1473
|
+
content: [
|
|
1474
|
+
{ type: 'video_url', video_url: { url: videoUrl }, fps },
|
|
1475
|
+
{ type: 'text', text: prompt },
|
|
1476
|
+
],
|
|
1477
|
+
}],
|
|
1478
|
+
}),
|
|
1479
|
+
signal: operation.signal,
|
|
1480
|
+
})
|
|
1481
|
+
operation.metrics.upstreamMs += Date.now() - started
|
|
1482
|
+
const body = await response.text().catch(() => '')
|
|
1483
|
+
if (!response.ok) {
|
|
1484
|
+
throw new VisionToolkitError('service', `video request failed with HTTP ${response.status}: ${body.slice(0, 300)}`)
|
|
1485
|
+
}
|
|
1486
|
+
const answer = extractChatAnswer(body)
|
|
1487
|
+
if (answer.trim().length === 0) {
|
|
1488
|
+
throw new VisionToolkitError('output', 'vision API returned an empty answer')
|
|
1489
|
+
}
|
|
1490
|
+
return answer
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
/** Local video basic-info probe: ffprobe metadata, no API or credential. */
|
|
1494
|
+
async videoInfo(request: VideoInfoRequest, options: ToolCallOptions): Promise<VideoInfo> {
|
|
1495
|
+
return this.runOperation('vision_video_info', options, async (operation) => {
|
|
1496
|
+
const policy = await this.pathPolicy(options.workspace)
|
|
1497
|
+
const resolved = await resolveInputVideo(request.video, policy)
|
|
1498
|
+
const metadata = await this.runFfprobe(resolved.path, operation)
|
|
1499
|
+
return { path: resolved.path, bytes: resolved.bytes, ...metadata }
|
|
1500
|
+
})
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Video understanding: upload the video to object storage and send it plus a
|
|
1505
|
+
* prompt to the first enabled OpenAI provider with video support. Whether the
|
|
1506
|
+
* tool appears in an Agent is governed by the Settings tool-visibility video
|
|
1507
|
+
* bucket, not by provider capability; this method re-checks capability at
|
|
1508
|
+
* call time and returns a "video understanding unavailable" config error when
|
|
1509
|
+
* no enabled provider supports video.
|
|
1510
|
+
*/
|
|
1511
|
+
async videoUnderstand(request: VideoUnderstandRequest, options: ToolCallOptions): Promise<VideoUnderstandResult> {
|
|
1512
|
+
return this.runOperation('vision_video_understand', options, async (operation) => {
|
|
1513
|
+
const target = this.videoProvider()
|
|
1514
|
+
if (target === undefined) {
|
|
1515
|
+
throw new VisionToolkitError('config', 'no enabled vision service has video support; enable it in Settings first')
|
|
1516
|
+
}
|
|
1517
|
+
const entry = await this.resolveProviderEnv(target)
|
|
1518
|
+
if (entry === undefined) {
|
|
1519
|
+
throw new VisionToolkitError('config', `credential ${String(target.credential)} is not configured`)
|
|
1520
|
+
}
|
|
1521
|
+
const prompt = request.prompt.trim()
|
|
1522
|
+
if (prompt.length === 0) {
|
|
1523
|
+
throw new VisionToolkitError('input', 'prompt must not be empty')
|
|
1524
|
+
}
|
|
1525
|
+
const fps = request.fps ?? 2
|
|
1526
|
+
if (!Number.isInteger(fps) || fps < 1 || fps > 120) {
|
|
1527
|
+
throw new VisionToolkitError('input', 'fps must be an integer between 1 and 120')
|
|
1528
|
+
}
|
|
1529
|
+
const policy = await this.pathPolicy(options.workspace)
|
|
1530
|
+
const resolved = await resolveInputVideo(request.video, policy)
|
|
1531
|
+
const client = await this.resolveObjectStorageClient()
|
|
1532
|
+
if (client === undefined) {
|
|
1533
|
+
throw new VisionToolkitError('config', 'object storage is not configured (endpoint, bucket, and credential are required)')
|
|
1534
|
+
}
|
|
1535
|
+
const uploaded = await client.uploadImage(resolved.path, videoMediaType(extname(resolved.path).toLowerCase()))
|
|
1536
|
+
try {
|
|
1537
|
+
const answer = await this.requestVideoAnswer(target, entry.env.VISION_API_KEY, uploaded.url, prompt, fps, operation)
|
|
1538
|
+
return { path: resolved.path, answer }
|
|
1539
|
+
} finally {
|
|
1540
|
+
await client.deleteObject(uploaded.key)
|
|
1541
|
+
}
|
|
1542
|
+
})
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1349
1545
|
/** Stable gate key for one provider's in-flight request cap. */
|
|
1350
1546
|
private providerGate(provider: ResolvedProvider): Semaphore {
|
|
1351
1547
|
const key = `${provider.baseUrl}\u0000${provider.model}\u0000${String(provider.credential)}`
|
package/src/tools.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type TraceRequest,
|
|
23
23
|
} from './runtime.ts'
|
|
24
24
|
import { platformTempDirectory } from './paths.ts'
|
|
25
|
+
import type { VideoInfoRequest, VideoUnderstandRequest } from './video.ts'
|
|
25
26
|
|
|
26
27
|
const renderJson = (_args: unknown, value: unknown): ContentBlock[] => [{
|
|
27
28
|
type: 'text',
|
|
@@ -48,8 +49,55 @@ export const VISION_TOOL_NAMES = {
|
|
|
48
49
|
dominantColors: 'vision_dominant_colors',
|
|
49
50
|
htmlScreenshot: 'vision_html_screenshot',
|
|
50
51
|
concurrency: 'vision_concurrency',
|
|
52
|
+
videoInfo: 'vision_video_info',
|
|
51
53
|
} as const
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Opt-in video-understanding tool name. Deliberately outside {@link VISION_TOOL_NAMES}
|
|
57
|
+
* so the always-registered canonical set stays unconditional; this tool enters an
|
|
58
|
+
* Agent only when a vision service has video support enabled.
|
|
59
|
+
*/
|
|
60
|
+
export const VISION_VIDEO_UNDERSTAND_TOOL = 'vision_video_understand'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tool-group buckets used by the `toolVisibility` snapshot. A bucket that is
|
|
64
|
+
* off contributes none of its listed tools to an Agent's visible surface.
|
|
65
|
+
* - local: local-processing tools (no on-line fan-out, no concurrency charge).
|
|
66
|
+
* - online: on-line image tools plus the concurrency/status probe.
|
|
67
|
+
* - video: video-understanding tool (experimental).
|
|
68
|
+
*/
|
|
69
|
+
export interface ToolVisibility {
|
|
70
|
+
local: boolean
|
|
71
|
+
online: boolean
|
|
72
|
+
video: boolean
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const TOOL_BUCKETS: Record<string, 'local' | 'online' | 'video'> = {
|
|
76
|
+
[VISION_TOOL_NAMES.glance]: 'online',
|
|
77
|
+
[VISION_TOOL_NAMES.ground]: 'online',
|
|
78
|
+
[VISION_TOOL_NAMES.detect]: 'online',
|
|
79
|
+
[VISION_TOOL_NAMES.longScreenshotOcr]: 'online',
|
|
80
|
+
[VISION_TOOL_NAMES.concurrency]: 'online',
|
|
81
|
+
[VISION_TOOL_NAMES.trace]: 'local',
|
|
82
|
+
[VISION_TOOL_NAMES.crop]: 'local',
|
|
83
|
+
[VISION_TOOL_NAMES.pixelDiff]: 'local',
|
|
84
|
+
[VISION_TOOL_NAMES.extractForeground]: 'local',
|
|
85
|
+
[VISION_TOOL_NAMES.dominantColors]: 'local',
|
|
86
|
+
[VISION_TOOL_NAMES.htmlScreenshot]: 'local',
|
|
87
|
+
[VISION_TOOL_NAMES.videoInfo]: 'local',
|
|
88
|
+
[VISION_VIDEO_UNDERSTAND_TOOL]: 'video',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Filter one tool definition by the visibility snapshot's matching bucket. */
|
|
92
|
+
function visibleBySnapshot(definition: { name: string }, snapshot: ToolVisibility): boolean {
|
|
93
|
+
switch (TOOL_BUCKETS[definition.name]) {
|
|
94
|
+
case 'local': return snapshot.local
|
|
95
|
+
case 'online': return snapshot.online
|
|
96
|
+
case 'video': return snapshot.video
|
|
97
|
+
default: return true
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
53
101
|
/** Resolve the caller workspace exactly like first-party fs/bash tools. */
|
|
54
102
|
function sessionWorkspace(exec: ToolRunContext): string {
|
|
55
103
|
return exec.agent?.session.header.cwd ?? process.cwd()
|
|
@@ -122,6 +170,19 @@ const requiredBoxSchema = { ...boxSchema, required: true } as const
|
|
|
122
170
|
const requiredImageInfoSchema = { ...imageInfoSchema, required: true } as const
|
|
123
171
|
const requiredArtifactSchema = { ...artifactSchema, required: true } as const
|
|
124
172
|
|
|
173
|
+
const nullableStringSchema = {
|
|
174
|
+
oneOf: [{ type: 'string' }, { type: 'null' }],
|
|
175
|
+
} as const satisfies ValueSchemaSpec
|
|
176
|
+
const nullableIntegerSchema = {
|
|
177
|
+
oneOf: [{ type: 'integer' }, { type: 'null' }],
|
|
178
|
+
} as const satisfies ValueSchemaSpec
|
|
179
|
+
const nullableNumberSchema = {
|
|
180
|
+
oneOf: [{ type: 'number' }, { type: 'null' }],
|
|
181
|
+
} as const satisfies ValueSchemaSpec
|
|
182
|
+
const requiredNullableStringSchema = { ...nullableStringSchema, required: true } as const
|
|
183
|
+
const requiredNullableIntegerSchema = { ...nullableIntegerSchema, required: true } as const
|
|
184
|
+
const requiredNullableNumberSchema = { ...nullableNumberSchema, required: true } as const
|
|
185
|
+
|
|
125
186
|
const locatedMatchSchema = {
|
|
126
187
|
type: 'object',
|
|
127
188
|
additionalProperties: false,
|
|
@@ -181,17 +242,21 @@ function runtimeFrom(source: VisionToolkitRuntimeSource): VisionToolkitRuntime {
|
|
|
181
242
|
* @param source - Current runtime or atomic runtime lookup.
|
|
182
243
|
* @param projectPresentation - Browser-only projection for Artifact capabilities.
|
|
183
244
|
* @param lifecycleSignal - Plugin lifetime; aborting it cancels every active tool call.
|
|
245
|
+
* @param toolVisibility - Session-head visibility snapshot; a bucket that is off
|
|
246
|
+
* contributes none of its tools. Defaults to every bucket on.
|
|
184
247
|
* @returns Native tool definitions registered as one lifecycle generation.
|
|
185
248
|
*/
|
|
186
249
|
export function createVisionTools(
|
|
187
250
|
source: VisionToolkitRuntimeSource,
|
|
188
251
|
projectPresentation: VisionToolkitPresentationProjector = presentationIdentity,
|
|
189
252
|
lifecycleSignal?: AbortSignal,
|
|
253
|
+
toolVisibility: ToolVisibility = { local: true, online: true, video: false },
|
|
190
254
|
): ReturnType<typeof defineTool>[] {
|
|
191
255
|
const presentationMeta = (_args: unknown, value: JsonValue): JsonValue => projectPresentation(value)
|
|
192
|
-
const
|
|
256
|
+
const runtime = runtimeFrom(source)
|
|
257
|
+
const sessionMaxConcurrency = runtime.sessionMaxConcurrency
|
|
193
258
|
const concurrencyNote = `A single session runs at most ${sessionMaxConcurrency} concurrent vision calls; query vision_concurrency for the live available count. `
|
|
194
|
-
|
|
259
|
+
const tools: ReturnType<typeof defineTool>[] = [
|
|
195
260
|
defineTool({
|
|
196
261
|
name: VISION_TOOL_NAMES.glance,
|
|
197
262
|
description: 'Describe, answer a targeted question about, OCR, or compare one or more images with the configured vision model. '
|
|
@@ -591,7 +656,8 @@ export function createVisionTools(
|
|
|
591
656
|
}),
|
|
592
657
|
defineTool({
|
|
593
658
|
name: VISION_TOOL_NAMES.concurrency,
|
|
594
|
-
description: 'Report the current available concurrency for vision tool calls in this session: the smaller of the remaining per-session slots and the total remaining model-request slots across enabled providers.'
|
|
659
|
+
description: 'Report the current available concurrency for ON-LINE vision tool calls (glance, ground, detect, long-screenshot OCR, video understanding) in this session: the smaller of the remaining per-session slots and the total remaining model-request slots across enabled providers. '
|
|
660
|
+
+ 'Local-processing tools (trace, crop, pixel diff, extract foreground, colors, HTML screenshot, video info) are NOT throttled by this and run immediately.',
|
|
595
661
|
parameters: {},
|
|
596
662
|
output: {
|
|
597
663
|
schema: {
|
|
@@ -621,7 +687,72 @@ export function createVisionTools(
|
|
|
621
687
|
isConcurrencySafe: () => true,
|
|
622
688
|
presentCall: () => ({ card: 'generic', title: 'Vision concurrency', kind: 'read', locations: [] }),
|
|
623
689
|
}),
|
|
690
|
+
defineTool({
|
|
691
|
+
name: VISION_TOOL_NAMES.videoInfo,
|
|
692
|
+
description: 'Probe a local video file and return its basic metadata (container format, duration, resolution, frame rate, codecs, and stream counts) using the bundled ffprobe binary. '
|
|
693
|
+
+ 'This is a local operation and never calls a vision API or needs a credential. ' + concurrencyNote + WORKSPACE_NOTE,
|
|
694
|
+
parameters: {
|
|
695
|
+
video: { type: 'string', required: true, description: 'Video file path.' },
|
|
696
|
+
timeoutSeconds: { type: 'integer', description: TIMEOUT_NOTE },
|
|
697
|
+
},
|
|
698
|
+
output: {
|
|
699
|
+
schema: {
|
|
700
|
+
type: 'object', additionalProperties: false, properties: {
|
|
701
|
+
path: { type: 'string', required: true },
|
|
702
|
+
bytes: { type: 'integer', required: true },
|
|
703
|
+
format: { type: 'string', required: true },
|
|
704
|
+
durationSeconds: requiredNullableNumberSchema,
|
|
705
|
+
width: requiredNullableIntegerSchema,
|
|
706
|
+
height: requiredNullableIntegerSchema,
|
|
707
|
+
frameRate: requiredNullableNumberSchema,
|
|
708
|
+
videoCodec: requiredNullableStringSchema,
|
|
709
|
+
audioCodec: requiredNullableStringSchema,
|
|
710
|
+
bitRate: requiredNullableIntegerSchema,
|
|
711
|
+
videoStreamCount: { type: 'integer', required: true },
|
|
712
|
+
audioStreamCount: { type: 'integer', required: true },
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
render: renderJson,
|
|
716
|
+
},
|
|
717
|
+
async execute(args: VideoInfoArgs, exec) {
|
|
718
|
+
const request: VideoInfoRequest = { video: args.video }
|
|
719
|
+
return runtimeFrom(source).videoInfo(request, callOptions(exec, args.timeoutSeconds, lifecycleSignal))
|
|
720
|
+
},
|
|
721
|
+
isConcurrencySafe: () => true,
|
|
722
|
+
presentCall: args => ({ card: 'generic', title: `Inspect ${args.video}`, kind: 'read', locations: [{ path: args.video }] }),
|
|
723
|
+
}),
|
|
724
|
+
defineTool({
|
|
725
|
+
name: VISION_VIDEO_UNDERSTAND_TOOL,
|
|
726
|
+
description: 'Send a local video plus a prompt to the configured vision service and return its answer text. '
|
|
727
|
+
+ 'The video is uploaded to object storage and passed as a video_url block (Aliyun Qwen format). '
|
|
728
|
+
+ `This tool exists only when the video tool bucket is enabled in Settings. Calling it without an enabled vision service that supports video returns a "video understanding unavailable" error. ${UNTRUSTED_EVIDENCE_NOTE} ` + concurrencyNote + WORKSPACE_NOTE,
|
|
729
|
+
parameters: {
|
|
730
|
+
video: { type: 'string', required: true, description: 'Video file path.' },
|
|
731
|
+
prompt: { type: 'string', required: true, description: 'Question or instruction about the video.' },
|
|
732
|
+
fps: { type: 'integer', description: 'Video sampling frame rate passed to the model; default 2.' },
|
|
733
|
+
timeoutSeconds: { type: 'integer', description: TIMEOUT_NOTE },
|
|
734
|
+
},
|
|
735
|
+
output: {
|
|
736
|
+
schema: {
|
|
737
|
+
type: 'object', additionalProperties: false, properties: {
|
|
738
|
+
path: { type: 'string', required: true },
|
|
739
|
+
answer: { type: 'string', required: true },
|
|
740
|
+
},
|
|
741
|
+
},
|
|
742
|
+
render: renderJson,
|
|
743
|
+
},
|
|
744
|
+
async execute(args: VideoUnderstandArgs, exec) {
|
|
745
|
+
const request: VideoUnderstandRequest = {
|
|
746
|
+
video: args.video,
|
|
747
|
+
prompt: args.prompt,
|
|
748
|
+
...(args.fps === undefined ? {} : { fps: args.fps }),
|
|
749
|
+
}
|
|
750
|
+
return runtimeFrom(source).videoUnderstand(request, callOptions(exec, args.timeoutSeconds, lifecycleSignal))
|
|
751
|
+
},
|
|
752
|
+
presentCall: args => ({ card: 'generic', title: `Understand ${args.video}`, kind: 'read', locations: [{ path: args.video }] }),
|
|
753
|
+
}),
|
|
624
754
|
]
|
|
755
|
+
return tools.filter(definition => visibleBySnapshot(definition, toolVisibility))
|
|
625
756
|
}
|
|
626
757
|
|
|
627
758
|
interface GlanceArgs {
|
|
@@ -722,3 +853,13 @@ interface HtmlArgs {
|
|
|
722
853
|
output?: string
|
|
723
854
|
timeoutSeconds?: number
|
|
724
855
|
}
|
|
856
|
+
interface VideoInfoArgs {
|
|
857
|
+
video: string
|
|
858
|
+
timeoutSeconds?: number
|
|
859
|
+
}
|
|
860
|
+
interface VideoUnderstandArgs {
|
|
861
|
+
video: string
|
|
862
|
+
prompt: string
|
|
863
|
+
fps?: number
|
|
864
|
+
timeoutSeconds?: number
|
|
865
|
+
}
|
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
|
+
}
|