@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.
Files changed (48) hide show
  1. package/assets/1.mp4 +0 -0
  2. package/assets/skill/SKILL.md +37 -5
  3. package/docs/plan-per-tool-visibility.md +82 -0
  4. package/lib/client.js +53 -3
  5. package/lib/client.js.map +1 -1
  6. package/lib/config.js +17 -0
  7. package/lib/config.js.map +1 -1
  8. package/lib/exposure.js +35 -8
  9. package/lib/exposure.js.map +1 -1
  10. package/lib/index.js +1 -1
  11. package/lib/index.js.map +1 -1
  12. package/lib/paths.js +9 -0
  13. package/lib/paths.js.map +1 -1
  14. package/lib/runtime.js +175 -1
  15. package/lib/runtime.js.map +1 -1
  16. package/lib/tools.js +116 -4
  17. package/lib/tools.js.map +1 -1
  18. package/lib/types/client/index.d.ts +24 -1
  19. package/lib/types/client/index.d.ts.map +1 -1
  20. package/lib/types/config.d.ts +26 -0
  21. package/lib/types/config.d.ts.map +1 -1
  22. package/lib/types/exposure.d.ts +14 -2
  23. package/lib/types/exposure.d.ts.map +1 -1
  24. package/lib/types/index.d.ts.map +1 -1
  25. package/lib/types/paths.d.ts +7 -0
  26. package/lib/types/paths.d.ts.map +1 -1
  27. package/lib/types/runtime.d.ts +38 -0
  28. package/lib/types/runtime.d.ts.map +1 -1
  29. package/lib/types/tools.d.ts +22 -1
  30. package/lib/types/tools.d.ts.map +1 -1
  31. package/lib/types/video.d.ts +78 -0
  32. package/lib/types/video.d.ts.map +1 -0
  33. package/lib/types/web.d.ts +1 -0
  34. package/lib/types/web.d.ts.map +1 -1
  35. package/lib/video.js +169 -0
  36. package/lib/video.js.map +1 -0
  37. package/lib/web.js +47 -4
  38. package/lib/web.js.map +1 -1
  39. package/package.json +2 -1
  40. package/src/client/index.tsx +72 -1
  41. package/src/config.ts +43 -0
  42. package/src/exposure.ts +34 -9
  43. package/src/index.ts +10 -5
  44. package/src/paths.ts +11 -0
  45. package/src/runtime.ts +196 -0
  46. package/src/tools.ts +144 -3
  47. package/src/video.ts +222 -0
  48. 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 sessionMaxConcurrency = runtimeFrom(source).sessionMaxConcurrency
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
- return [
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
+ }