@sylphx/video-reader-mcp 0.1.0 → 0.1.1

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 (43) hide show
  1. package/README.md +222 -41
  2. package/bin/native/video-reader-mcp-server +0 -0
  3. package/bin/video-reader-mcp +48 -0
  4. package/dist/doctor-cli.js +167 -0
  5. package/package.json +23 -10
  6. package/src/doctor-cli.ts +18 -0
  7. package/src/doctor.ts +132 -0
  8. package/src/engine/rust-asr.ts +98 -0
  9. package/src/engine/rust-frames.ts +95 -0
  10. package/src/engine/rust-timeline.ts +164 -0
  11. package/src/engine/rust-video-evidence.ts +132 -0
  12. package/src/handlers/readVideo.ts +41 -0
  13. package/src/handlers/videoEvidence.ts +161 -0
  14. package/src/mcp.ts +302 -0
  15. package/src/schemas/readVideo.ts +67 -0
  16. package/src/schemas/videoEvidence.ts +53 -0
  17. package/src/sdk.ts +44 -0
  18. package/src/types/timeline.ts +106 -0
  19. package/src/utils/asr.ts +59 -0
  20. package/src/utils/errors.ts +16 -0
  21. package/src/utils/exec.ts +76 -0
  22. package/src/utils/ffmpeg.ts +81 -0
  23. package/src/utils/ffprobe.ts +137 -0
  24. package/src/utils/frameOcr.ts +99 -0
  25. package/src/utils/frames.ts +93 -0
  26. package/src/utils/pathUtils.ts +36 -0
  27. package/src/utils/scenes.ts +63 -0
  28. package/src/utils/subtitles.ts +101 -0
  29. package/src/video/readCoordinator.ts +165 -0
  30. package/dist/handlers/readVideo.js +0 -24
  31. package/dist/index.js +0 -59
  32. package/dist/mcp.js +0 -181
  33. package/dist/schemas/readVideo.js +0 -33
  34. package/dist/types/timeline.js +0 -1
  35. package/dist/utils/asr.js +0 -26
  36. package/dist/utils/errors.js +0 -14
  37. package/dist/utils/exec.js +0 -56
  38. package/dist/utils/ffmpeg.js +0 -66
  39. package/dist/utils/ffprobe.js +0 -79
  40. package/dist/utils/pathUtils.js +0 -31
  41. package/dist/utils/scenes.js +0 -49
  42. package/dist/utils/subtitles.js +0 -85
  43. package/dist/video/readCoordinator.js +0 -81
package/src/doctor.ts ADDED
@@ -0,0 +1,132 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { resolveRustCliBinary } from './engine/rust-timeline.js';
6
+ import { isBinaryAvailable } from './utils/exec.js';
7
+
8
+ const here = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ export type DoctorStatus = 'ok' | 'warn' | 'fail';
11
+
12
+ export interface DoctorCheck {
13
+ id: string;
14
+ status: DoctorStatus;
15
+ message: string;
16
+ }
17
+
18
+ export interface DoctorReport {
19
+ profile: 'video_reader_doctor';
20
+ version: string;
21
+ status: 'ready' | 'degraded' | 'unavailable';
22
+ checks: DoctorCheck[];
23
+ }
24
+
25
+ const probeBinary = async (id: string, binary: string, required: boolean): Promise<DoctorCheck> => {
26
+ const available = await isBinaryAvailable(binary);
27
+ if (available) {
28
+ return {
29
+ id,
30
+ status: 'ok',
31
+ message: `${binary} is installed and responds to -version.`,
32
+ };
33
+ }
34
+
35
+ return {
36
+ id,
37
+ status: required ? 'fail' : 'warn',
38
+ message: required
39
+ ? `${binary} is required for read_video but was not found on PATH.`
40
+ : `${binary} is optional for some extraction paths.`,
41
+ };
42
+ };
43
+
44
+ const probeRustTimelineCli = (): DoctorCheck => {
45
+ const binary = resolveRustCliBinary();
46
+ if (binary !== 'video-reader-cli' && existsSync(binary)) {
47
+ const probe = spawnSync(binary, [], {
48
+ input: JSON.stringify({
49
+ tool: 'build_cache_key',
50
+ input: {
51
+ source_hash: 'abc123',
52
+ options: { include_streams: true, include_chapters: true },
53
+ },
54
+ }),
55
+ encoding: 'utf8',
56
+ timeout: 5_000,
57
+ });
58
+
59
+ if (probe.status === 0) {
60
+ return {
61
+ id: 'rust_timeline_cli',
62
+ status: 'ok',
63
+ message: `Rust timeline CLI is available at ${binary}.`,
64
+ };
65
+ }
66
+ }
67
+
68
+ const release = path.join(here, '../target/release/video-reader-cli');
69
+ const debug = path.join(here, '../target/debug/video-reader-cli');
70
+ if (existsSync(release) || existsSync(debug)) {
71
+ return {
72
+ id: 'rust_timeline_cli',
73
+ status: 'ok',
74
+ message: 'Rust timeline CLI is built locally.',
75
+ };
76
+ }
77
+
78
+ return {
79
+ id: 'rust_timeline_cli',
80
+ status: 'warn',
81
+ message:
82
+ 'Rust timeline CLI is not built. Run `cargo build --release` to enable VIDEO_READER_USE_RUST_TIMELINE=1.',
83
+ };
84
+ };
85
+
86
+ const probeNode = (): DoctorCheck => {
87
+ const version = process.versions.node;
88
+ const major = Number.parseInt(version.split('.')[0] ?? '0', 10);
89
+ if (major >= 22) {
90
+ return {
91
+ id: 'node',
92
+ status: 'ok',
93
+ message: `Node.js ${version} meets the >=22.13 requirement.`,
94
+ };
95
+ }
96
+
97
+ return {
98
+ id: 'node',
99
+ status: 'warn',
100
+ message: `Node.js ${version} is below the recommended >=22.13 runtime.`,
101
+ };
102
+ };
103
+
104
+ const aggregateStatus = (checks: DoctorCheck[]): DoctorReport['status'] => {
105
+ if (checks.some((check) => check.status === 'fail')) {
106
+ return 'unavailable';
107
+ }
108
+ if (checks.some((check) => check.status === 'warn')) {
109
+ return 'degraded';
110
+ }
111
+ return 'ready';
112
+ };
113
+
114
+ export async function runDoctor(version: string): Promise<DoctorReport> {
115
+ const checks = [
116
+ probeNode(),
117
+ probeRustTimelineCli(),
118
+ await probeBinary('ffprobe', 'ffprobe', true),
119
+ await probeBinary('ffmpeg', 'ffmpeg', false),
120
+ ];
121
+
122
+ return {
123
+ profile: 'video_reader_doctor',
124
+ version,
125
+ status: aggregateStatus(checks),
126
+ checks,
127
+ };
128
+ }
129
+
130
+ export function formatDoctorReport(report: DoctorReport): string {
131
+ return JSON.stringify(report, null, 2);
132
+ }
@@ -0,0 +1,98 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { TranscriptSegment } from '../types/timeline.js';
6
+
7
+ export type RustAsrResult = {
8
+ transcript: TranscriptSegment[];
9
+ route: string;
10
+ adapter?: string;
11
+ warning?: string;
12
+ };
13
+
14
+ type RustAsrEnvelope =
15
+ | { status: 'ok'; asr: RustAsrResult }
16
+ | { status: 'error'; code: string; message: string };
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+
20
+ export function resolveRustCliBinary(): string {
21
+ const env = process.env.VIDEO_READER_CLI;
22
+ if (env && existsSync(env)) {
23
+ return env;
24
+ }
25
+
26
+ const release = path.join(here, '../../target/release/video-reader-cli');
27
+ if (existsSync(release)) {
28
+ return release;
29
+ }
30
+
31
+ const debug = path.join(here, '../../target/debug/video-reader-cli');
32
+ if (existsSync(debug)) {
33
+ return debug;
34
+ }
35
+
36
+ return 'video-reader-cli';
37
+ }
38
+
39
+ export function isRustCliAvailable(): boolean {
40
+ return resolveRustCliBinary() !== 'video-reader-cli';
41
+ }
42
+
43
+ export function shouldUseRustAsrEngine(): boolean {
44
+ if (process.env.VIDEO_READER_USE_RUST_ASR === '0') {
45
+ return false;
46
+ }
47
+ if (process.env.VIDEO_READER_USE_RUST_ASR === '1') {
48
+ return true;
49
+ }
50
+ return isRustCliAvailable();
51
+ }
52
+
53
+ export function transcribeViaRustEngine(
54
+ videoPath: string,
55
+ maxAudioSeconds = 300
56
+ ): { ok: true; result: RustAsrResult } | { ok: false; code: string; message: string } {
57
+ const binary = resolveRustCliBinary();
58
+ const payload = JSON.stringify({
59
+ tool: 'transcribe_asr',
60
+ input: {
61
+ path: videoPath,
62
+ max_audio_seconds: maxAudioSeconds,
63
+ },
64
+ });
65
+
66
+ const response = spawnSync(binary, [], {
67
+ input: payload,
68
+ encoding: 'utf8',
69
+ maxBuffer: 16 * 1024 * 1024,
70
+ });
71
+
72
+ if (response.error) {
73
+ return {
74
+ ok: false,
75
+ code: 'ENGINE_UNAVAILABLE',
76
+ message: response.error.message,
77
+ };
78
+ }
79
+
80
+ if (response.status !== 0) {
81
+ return {
82
+ ok: false,
83
+ code: 'ENGINE_FAILED',
84
+ message: response.stderr || `Rust ASR engine exited with status ${response.status}`,
85
+ };
86
+ }
87
+
88
+ const envelope = JSON.parse(response.stdout) as RustAsrEnvelope;
89
+ if (envelope.status !== 'ok') {
90
+ return {
91
+ ok: false,
92
+ code: envelope.code,
93
+ message: envelope.message,
94
+ };
95
+ }
96
+
97
+ return { ok: true, result: envelope.asr };
98
+ }
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { FrameEvidence } from '../types/timeline.js';
6
+
7
+ type RustKeyframeEnvelope =
8
+ | { status: 'ok'; keyframes: FrameEvidence[] }
9
+ | { status: 'error'; code: string; message: string };
10
+
11
+ const here = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ export function resolveRustCliBinary(): string {
14
+ const env = process.env.VIDEO_READER_CLI;
15
+ if (env && existsSync(env)) {
16
+ return env;
17
+ }
18
+
19
+ const release = path.join(here, '../../target/release/video-reader-cli');
20
+ if (existsSync(release)) {
21
+ return release;
22
+ }
23
+
24
+ const debug = path.join(here, '../../target/debug/video-reader-cli');
25
+ if (existsSync(debug)) {
26
+ return debug;
27
+ }
28
+
29
+ return 'video-reader-cli';
30
+ }
31
+
32
+ export function isRustCliAvailable(): boolean {
33
+ return resolveRustCliBinary() !== 'video-reader-cli';
34
+ }
35
+
36
+ export function shouldUseRustFramesEngine(): boolean {
37
+ if (process.env.VIDEO_READER_USE_RUST_FRAMES === '0') {
38
+ return false;
39
+ }
40
+ if (process.env.VIDEO_READER_USE_RUST_FRAMES === '1') {
41
+ return true;
42
+ }
43
+ return isRustCliAvailable();
44
+ }
45
+
46
+ export function extractKeyframesViaRustEngine(input: {
47
+ videoPath: string;
48
+ limit: number;
49
+ includeImages: boolean;
50
+ maxDimension?: number | undefined;
51
+ }): { ok: true; keyframes: FrameEvidence[] } | { ok: false; code: string; message: string } {
52
+ const binary = resolveRustCliBinary();
53
+ const payload = JSON.stringify({
54
+ tool: 'extract_keyframes',
55
+ input: {
56
+ path: input.videoPath,
57
+ limit: input.limit,
58
+ include_images: input.includeImages,
59
+ ...(input.maxDimension !== undefined ? { max_dimension: input.maxDimension } : {}),
60
+ },
61
+ });
62
+
63
+ const response = spawnSync(binary, [], {
64
+ input: payload,
65
+ encoding: 'utf8',
66
+ maxBuffer: 32 * 1024 * 1024,
67
+ });
68
+
69
+ if (response.error) {
70
+ return {
71
+ ok: false,
72
+ code: 'ENGINE_UNAVAILABLE',
73
+ message: response.error.message,
74
+ };
75
+ }
76
+
77
+ if (response.status !== 0) {
78
+ return {
79
+ ok: false,
80
+ code: 'ENGINE_FAILED',
81
+ message: response.stderr || `Rust frames engine exited with status ${response.status}`,
82
+ };
83
+ }
84
+
85
+ const envelope = JSON.parse(response.stdout) as RustKeyframeEnvelope;
86
+ if (envelope.status !== 'ok') {
87
+ return {
88
+ ok: false,
89
+ code: envelope.code,
90
+ message: envelope.message,
91
+ };
92
+ }
93
+
94
+ return { ok: true, keyframes: envelope.keyframes };
95
+ }
@@ -0,0 +1,164 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import type { FfprobeResult } from '../utils/ffprobe.js';
6
+
7
+ export type RustProbeTimeline = {
8
+ format: {
9
+ format_name?: string;
10
+ duration_ms: number;
11
+ bit_rate?: number;
12
+ size_bytes?: number;
13
+ tags?: Record<string, string>;
14
+ };
15
+ streams: Array<{
16
+ index: number;
17
+ codec_type: string;
18
+ codec_name?: string;
19
+ language?: string;
20
+ channels?: number;
21
+ sample_rate?: number;
22
+ width?: number;
23
+ height?: number;
24
+ avg_frame_rate?: string;
25
+ r_frame_rate?: string;
26
+ bit_rate?: number;
27
+ disposition?: Record<string, number>;
28
+ tags?: Record<string, string>;
29
+ }>;
30
+ chapters: Array<{
31
+ id: number;
32
+ start_ms: number;
33
+ end_ms: number;
34
+ title?: string;
35
+ }>;
36
+ warnings: string[];
37
+ route: string;
38
+ };
39
+
40
+ type RustTimelineEnvelope =
41
+ | { status: 'ok'; timeline: RustProbeTimeline }
42
+ | { status: 'error'; code: string; message: string };
43
+
44
+ type RustHashEnvelope =
45
+ | { status: 'ok'; source_hash: string }
46
+ | { status: 'error'; code: string; message: string };
47
+
48
+ type RustCacheKeyEnvelope =
49
+ | { status: 'ok'; cache_key: string }
50
+ | { status: 'error'; code: string; message: string };
51
+
52
+ const here = path.dirname(fileURLToPath(import.meta.url));
53
+
54
+ export function resolveRustCliBinary(): string {
55
+ const env = process.env.VIDEO_READER_CLI;
56
+ if (env && existsSync(env)) {
57
+ return env;
58
+ }
59
+
60
+ const release = path.join(here, '../../target/release/video-reader-cli');
61
+ if (existsSync(release)) {
62
+ return release;
63
+ }
64
+
65
+ const debug = path.join(here, '../../target/debug/video-reader-cli');
66
+ if (existsSync(debug)) {
67
+ return debug;
68
+ }
69
+
70
+ return 'video-reader-cli';
71
+ }
72
+
73
+ export function shouldUseRustTimelineEngine(): boolean {
74
+ return process.env.VIDEO_READER_USE_RUST_TIMELINE === '1';
75
+ }
76
+
77
+ const invokeRustCli = (tool: string, input: Record<string, unknown>): unknown => {
78
+ const binary = resolveRustCliBinary();
79
+ const payload = JSON.stringify({ tool, input });
80
+
81
+ const result = spawnSync(binary, [], {
82
+ input: payload,
83
+ encoding: 'utf8',
84
+ maxBuffer: 4 * 1024 * 1024,
85
+ });
86
+
87
+ if (result.error) {
88
+ throw new Error(`Failed to launch video timeline engine: ${result.error.message}`);
89
+ }
90
+
91
+ if (result.status !== 0) {
92
+ throw new Error(result.stderr || `Video timeline engine exited with status ${result.status}`);
93
+ }
94
+
95
+ return JSON.parse(result.stdout) as unknown;
96
+ };
97
+
98
+ export function assembleProbeTimelineViaRustEngine(
99
+ probe: FfprobeResult,
100
+ options: { includeStreams: boolean; includeChapters: boolean }
101
+ ): RustProbeTimeline {
102
+ const envelope = invokeRustCli('assemble_probe_timeline', {
103
+ ffprobe: probe,
104
+ options: {
105
+ include_streams: options.includeStreams,
106
+ include_chapters: options.includeChapters,
107
+ },
108
+ }) as RustTimelineEnvelope;
109
+
110
+ if (envelope.status !== 'ok') {
111
+ throw new Error(envelope.message);
112
+ }
113
+
114
+ return envelope.timeline;
115
+ }
116
+
117
+ export function hashSourceViaRustEngine(filePath: string): string {
118
+ const envelope = invokeRustCli('hash_source', { path: filePath }) as RustHashEnvelope;
119
+ if (envelope.status !== 'ok') {
120
+ throw new Error(envelope.message);
121
+ }
122
+
123
+ return envelope.source_hash;
124
+ }
125
+
126
+ export function buildCacheKeyViaRustEngine(
127
+ sourceHash: string,
128
+ options: {
129
+ includeStreams: boolean;
130
+ includeChapters: boolean;
131
+ includeSubtitles: boolean;
132
+ includeScenes: boolean;
133
+ includeTranscript: boolean;
134
+ includeKeyframes: boolean;
135
+ includeKeyframeImages: boolean;
136
+ keyframeLimit: number;
137
+ keyframeMaxDimension?: number | undefined;
138
+ sceneThreshold: number;
139
+ }
140
+ ): string {
141
+ const envelope = invokeRustCli('build_cache_key', {
142
+ source_hash: sourceHash,
143
+ options: {
144
+ include_streams: options.includeStreams,
145
+ include_chapters: options.includeChapters,
146
+ include_subtitles: options.includeSubtitles,
147
+ include_scenes: options.includeScenes,
148
+ include_transcript: options.includeTranscript,
149
+ include_keyframes: options.includeKeyframes,
150
+ include_keyframe_images: options.includeKeyframeImages,
151
+ keyframe_limit: options.keyframeLimit,
152
+ ...(options.keyframeMaxDimension !== undefined
153
+ ? { keyframe_max_dimension: options.keyframeMaxDimension }
154
+ : {}),
155
+ scene_threshold: options.sceneThreshold,
156
+ },
157
+ }) as RustCacheKeyEnvelope;
158
+
159
+ if (envelope.status !== 'ok') {
160
+ throw new Error(envelope.message);
161
+ }
162
+
163
+ return envelope.cache_key;
164
+ }
@@ -0,0 +1,132 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ export type CropRegion = {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ };
12
+
13
+ export type FrameRenderEvidence = {
14
+ time_ms: number;
15
+ route: string;
16
+ frame_hash: string;
17
+ mime: string;
18
+ width: number;
19
+ height: number;
20
+ image_base64: string;
21
+ provenance: {
22
+ method: string;
23
+ time_ms: number;
24
+ };
25
+ crop?: CropRegion | undefined;
26
+ };
27
+
28
+ type FrameRenderEnvelope =
29
+ | { status: 'ok'; frame: FrameRenderEvidence }
30
+ | { status: 'error'; code: string; message: string };
31
+
32
+ const here = path.dirname(fileURLToPath(import.meta.url));
33
+
34
+ export function resolveRustCliBinary(): string {
35
+ const env = process.env.VIDEO_READER_CLI;
36
+ if (env && existsSync(env)) {
37
+ return env;
38
+ }
39
+
40
+ const release = path.join(here, '../../target/release/video-reader-cli');
41
+ if (existsSync(release)) {
42
+ return release;
43
+ }
44
+
45
+ const debug = path.join(here, '../../target/debug/video-reader-cli');
46
+ if (existsSync(debug)) {
47
+ return debug;
48
+ }
49
+
50
+ return 'video-reader-cli';
51
+ }
52
+
53
+ export function isRustCliAvailable(): boolean {
54
+ return resolveRustCliBinary() !== 'video-reader-cli';
55
+ }
56
+
57
+ export function shouldUseRustVideoEvidenceEngine(): boolean {
58
+ if (process.env.VIDEO_READER_USE_RUST_FRAMES === '0') {
59
+ return false;
60
+ }
61
+ if (process.env.VIDEO_READER_USE_RUST_FRAMES === '1') {
62
+ return true;
63
+ }
64
+ return isRustCliAvailable();
65
+ }
66
+
67
+ function invokeRustFrameTool(
68
+ tool: 'render_frame' | 'crop_frame',
69
+ input: Record<string, unknown>
70
+ ): { ok: true; frame: FrameRenderEvidence } | { ok: false; code: string; message: string } {
71
+ const binary = resolveRustCliBinary();
72
+ const payload = JSON.stringify({ tool, input });
73
+ const response = spawnSync(binary, [], {
74
+ input: payload,
75
+ encoding: 'utf8',
76
+ maxBuffer: 32 * 1024 * 1024,
77
+ });
78
+
79
+ if (response.error) {
80
+ return {
81
+ ok: false,
82
+ code: 'ENGINE_UNAVAILABLE',
83
+ message: response.error.message,
84
+ };
85
+ }
86
+
87
+ if (response.status !== 0) {
88
+ return {
89
+ ok: false,
90
+ code: 'ENGINE_FAILED',
91
+ message:
92
+ response.stderr || `Rust video evidence engine exited with status ${response.status}`,
93
+ };
94
+ }
95
+
96
+ const envelope = JSON.parse(response.stdout) as FrameRenderEnvelope;
97
+ if (envelope.status !== 'ok') {
98
+ return {
99
+ ok: false,
100
+ code: envelope.code,
101
+ message: envelope.message,
102
+ };
103
+ }
104
+
105
+ return { ok: true, frame: envelope.frame };
106
+ }
107
+
108
+ export function renderFrameViaRustEngine(input: {
109
+ videoPath: string;
110
+ timeMs: number;
111
+ maxDimension?: number | undefined;
112
+ }) {
113
+ return invokeRustFrameTool('render_frame', {
114
+ path: input.videoPath,
115
+ time_ms: input.timeMs,
116
+ ...(input.maxDimension !== undefined ? { max_dimension: input.maxDimension } : {}),
117
+ });
118
+ }
119
+
120
+ export function cropFrameViaRustEngine(input: {
121
+ videoPath: string;
122
+ timeMs: number;
123
+ crop: CropRegion;
124
+ maxDimension?: number | undefined;
125
+ }) {
126
+ return invokeRustFrameTool('crop_frame', {
127
+ path: input.videoPath,
128
+ time_ms: input.timeMs,
129
+ crop: input.crop,
130
+ ...(input.maxDimension !== undefined ? { max_dimension: input.maxDimension } : {}),
131
+ });
132
+ }
@@ -0,0 +1,41 @@
1
+ import { text, tool, toolError } from '../mcp.js';
2
+ import { type ReadVideoArgs, readVideoArgsSchema } from '../schemas/readVideo.js';
3
+ import { processVideoSource } from '../video/readCoordinator.js';
4
+
5
+ const MAX_CONCURRENT_SOURCES = 2;
6
+
7
+ export const createReadVideoHandler = (version: string) =>
8
+ tool()
9
+ .description(
10
+ 'Primary video reader. Returns a timeline document with ffprobe metadata, embedded subtitles, optional scene boundaries, and warnings — no per-frame vision LLM.'
11
+ )
12
+ .input(readVideoArgsSchema)
13
+ .handler(async ({ input }: { input: ReadVideoArgs }) => {
14
+ const results = [];
15
+
16
+ for (let i = 0; i < input.sources.length; i += MAX_CONCURRENT_SOURCES) {
17
+ const batch = input.sources.slice(i, i + MAX_CONCURRENT_SOURCES);
18
+ const batchResults = await Promise.all(
19
+ batch.map((source) => processVideoSource(source.path, input, version))
20
+ );
21
+ results.push(...batchResults);
22
+ }
23
+
24
+ const allFailed = results.every((result) => !result.success);
25
+ if (allFailed) {
26
+ const errorMessages = results.map((result) => result.error).join('; ');
27
+ return toolError(`All video sources failed to process: ${errorMessages}`);
28
+ }
29
+
30
+ return text(
31
+ JSON.stringify(
32
+ {
33
+ results,
34
+ },
35
+ null,
36
+ 2
37
+ )
38
+ );
39
+ });
40
+
41
+ export const readVideo = createReadVideoHandler('0.1.0');