@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
@@ -0,0 +1,161 @@
1
+ import {
2
+ cropFrameViaRustEngine,
3
+ renderFrameViaRustEngine,
4
+ shouldUseRustVideoEvidenceEngine,
5
+ } from '../engine/rust-video-evidence.js';
6
+ import { text, tool, toolError } from '../mcp.js';
7
+ import { type VideoEvidenceArgs, videoEvidenceArgsSchema } from '../schemas/videoEvidence.js';
8
+ import { ocrPngBase64 } from '../utils/frameOcr.js';
9
+ import { resolvePath } from '../utils/pathUtils.js';
10
+
11
+ type EvidenceResult = {
12
+ source: string;
13
+ success: boolean;
14
+ time_ms: number;
15
+ operation: VideoEvidenceArgs['operation'];
16
+ route?: string | undefined;
17
+ frame?: {
18
+ frame_hash: string;
19
+ mime: string;
20
+ width: number;
21
+ height: number;
22
+ image_base64?: string;
23
+ provenance: { method: string; time_ms: number };
24
+ crop?: VideoEvidenceArgs['sources'][number]['crop'];
25
+ };
26
+ ocr?: {
27
+ available: boolean;
28
+ route: string;
29
+ skipped_reason?: string;
30
+ languages: string[];
31
+ lines: { text: string; confidence?: number }[];
32
+ line_count: number;
33
+ };
34
+ error?: string | undefined;
35
+ code?: string | undefined;
36
+ };
37
+
38
+ const routeForOperation = (operation: VideoEvidenceArgs['operation']): string => {
39
+ switch (operation) {
40
+ case 'render_frame':
41
+ return 'rust-frame-render';
42
+ case 'crop_frame':
43
+ return 'rust-frame-crop';
44
+ case 'ocr_frame':
45
+ return 'rust-frame-render+tesseract_frame';
46
+ }
47
+ };
48
+
49
+ export const createVideoEvidenceHandler = () =>
50
+ tool()
51
+ .description(
52
+ 'Runs focused video evidence follow-up operations: render_frame, crop_frame, or ocr_frame with timestamp locators after read_video.'
53
+ )
54
+ .input(videoEvidenceArgsSchema)
55
+ .handler(async ({ input }: { input: VideoEvidenceArgs }) => {
56
+ if (!shouldUseRustVideoEvidenceEngine()) {
57
+ return toolError(
58
+ 'Rust video evidence engine is unavailable. Build video-reader-cli with cargo build --release or set VIDEO_READER_CLI.'
59
+ );
60
+ }
61
+
62
+ const results: EvidenceResult[] = [];
63
+ const ocrLanguages = (input as { ocr_languages?: string[] }).ocr_languages ??
64
+ (input as { languages?: string[] }).languages ?? ['eng'];
65
+
66
+ for (const source of input.sources) {
67
+ const resolvedPath = resolvePath(source.path);
68
+ const engineResult =
69
+ input.operation === 'crop_frame'
70
+ ? cropFrameViaRustEngine({
71
+ videoPath: resolvedPath,
72
+ timeMs: source.time_ms,
73
+ crop: source.crop as NonNullable<typeof source.crop>,
74
+ maxDimension: input.max_dimension,
75
+ })
76
+ : renderFrameViaRustEngine({
77
+ videoPath: resolvedPath,
78
+ timeMs: source.time_ms,
79
+ maxDimension: input.max_dimension,
80
+ });
81
+
82
+ if (!engineResult.ok) {
83
+ results.push({
84
+ source: source.path,
85
+ success: false,
86
+ time_ms: source.time_ms,
87
+ operation: input.operation,
88
+ error: engineResult.message,
89
+ code: engineResult.code,
90
+ });
91
+ continue;
92
+ }
93
+
94
+ if (input.operation === 'ocr_frame') {
95
+ const ocr = ocrPngBase64(engineResult.frame.image_base64, ocrLanguages);
96
+ results.push({
97
+ source: source.path,
98
+ success: true,
99
+ time_ms: source.time_ms,
100
+ operation: 'ocr_frame',
101
+ route: ocr.available
102
+ ? 'rust-frame-render+tesseract_frame'
103
+ : 'rust-frame-render+tesseract_unavailable',
104
+ frame: {
105
+ frame_hash: engineResult.frame.frame_hash,
106
+ mime: engineResult.frame.mime,
107
+ width: engineResult.frame.width,
108
+ height: engineResult.frame.height,
109
+ // omit large base64 by default for OCR answers (hash is the locator)
110
+ provenance: engineResult.frame.provenance,
111
+ },
112
+ ocr: {
113
+ available: ocr.available,
114
+ route: ocr.route,
115
+ languages: ocr.languages,
116
+ lines: ocr.lines,
117
+ line_count: ocr.line_count,
118
+ ...(ocr.skipped_reason !== undefined ? { skipped_reason: ocr.skipped_reason } : {}),
119
+ },
120
+ });
121
+ continue;
122
+ }
123
+
124
+ results.push({
125
+ source: source.path,
126
+ success: true,
127
+ time_ms: source.time_ms,
128
+ operation: input.operation,
129
+ route: engineResult.frame.route,
130
+ frame: {
131
+ frame_hash: engineResult.frame.frame_hash,
132
+ mime: engineResult.frame.mime,
133
+ width: engineResult.frame.width,
134
+ height: engineResult.frame.height,
135
+ image_base64: engineResult.frame.image_base64,
136
+ provenance: engineResult.frame.provenance,
137
+ ...(engineResult.frame.crop ? { crop: engineResult.frame.crop } : {}),
138
+ },
139
+ });
140
+ }
141
+
142
+ if (results.every((result) => !result.success)) {
143
+ const messages = results.map((result) => result.error).join('; ');
144
+ return toolError(`All video evidence sources failed: ${messages}`);
145
+ }
146
+
147
+ return text(
148
+ JSON.stringify(
149
+ {
150
+ profile: 'video_evidence_results',
151
+ operation: input.operation,
152
+ route: routeForOperation(input.operation),
153
+ results,
154
+ },
155
+ null,
156
+ 2
157
+ )
158
+ );
159
+ });
160
+
161
+ export const videoEvidence = createVideoEvidenceHandler();
package/src/mcp.ts ADDED
@@ -0,0 +1,302 @@
1
+ import { createHash, timingSafeEqual } from 'node:crypto';
2
+ import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import type {
7
+ CallToolResult,
8
+ ContentBlock,
9
+ ImageContent,
10
+ TextContent,
11
+ } from '@modelcontextprotocol/sdk/types.js';
12
+ import { z } from 'zod';
13
+
14
+ export const text = (value: string): TextContent => ({ type: 'text', text: value });
15
+
16
+ export const image = (data: string, mimeType: string): ImageContent => ({
17
+ type: 'image',
18
+ data,
19
+ mimeType,
20
+ });
21
+
22
+ export const toolError = (message: string): CallToolResult => ({
23
+ content: [text(message)],
24
+ isError: true,
25
+ });
26
+
27
+ export type ToolHandlerResult = CallToolResult | ContentBlock | readonly ContentBlock[];
28
+
29
+ export type ToolHandler<TInput> = (args: {
30
+ input: TInput;
31
+ ctx: unknown;
32
+ }) => ToolHandlerResult | Promise<ToolHandlerResult>;
33
+
34
+ export interface ToolDefinition<TInput = unknown> {
35
+ description: string;
36
+ inputSchema: z.ZodType<TInput>;
37
+ handler(args: { input: TInput; ctx: unknown }): ToolHandlerResult | Promise<ToolHandlerResult>;
38
+ }
39
+
40
+ class ToolBuilder<TInput = unknown> {
41
+ readonly #description: string | undefined;
42
+ readonly #inputSchema: z.ZodType<TInput> | undefined;
43
+
44
+ constructor(descriptionValue?: string, inputSchema?: z.ZodType<TInput>) {
45
+ this.#description = descriptionValue;
46
+ this.#inputSchema = inputSchema;
47
+ }
48
+
49
+ description(value: string): ToolBuilder<TInput> {
50
+ return new ToolBuilder(value, this.#inputSchema);
51
+ }
52
+
53
+ input<TSchema extends z.ZodType>(schema: TSchema): ToolBuilder<z.infer<TSchema>> {
54
+ return new ToolBuilder(this.#description, schema as z.ZodType<z.infer<TSchema>>);
55
+ }
56
+
57
+ handler(handler: ToolHandler<TInput>): ToolDefinition<TInput> {
58
+ return {
59
+ description: this.#description ?? '',
60
+ inputSchema: this.#inputSchema ?? (z.object({}) as z.ZodType<TInput>),
61
+ handler,
62
+ };
63
+ }
64
+ }
65
+
66
+ export const tool = (): ToolBuilder => new ToolBuilder();
67
+
68
+ interface StdioTransportConfig {
69
+ kind: 'stdio';
70
+ }
71
+
72
+ interface HttpTransportConfig {
73
+ kind: 'http';
74
+ port: number;
75
+ hostname: string;
76
+ cors?: string;
77
+ apiKey?: string;
78
+ }
79
+
80
+ type TransportConfig = StdioTransportConfig | HttpTransportConfig;
81
+
82
+ export const stdio = (): StdioTransportConfig => ({ kind: 'stdio' });
83
+
84
+ export const http = (config: {
85
+ port: number;
86
+ hostname: string;
87
+ cors?: string;
88
+ apiKey?: string;
89
+ }): HttpTransportConfig => ({ kind: 'http', ...config });
90
+
91
+ interface CreateServerOptions {
92
+ name: string;
93
+ version: string;
94
+ instructions?: string;
95
+ tools: Record<string, ToolDefinition<unknown>>;
96
+ transport: TransportConfig;
97
+ }
98
+
99
+ interface ServerHandle {
100
+ start(): Promise<void>;
101
+ close(): Promise<void>;
102
+ }
103
+
104
+ const isCallToolResult = (result: ToolHandlerResult): result is CallToolResult =>
105
+ typeof result === 'object' && result !== null && 'content' in result;
106
+
107
+ const isContentArray = (result: ToolHandlerResult): result is readonly ContentBlock[] =>
108
+ Array.isArray(result);
109
+
110
+ const normalizeToolResult = (result: ToolHandlerResult): CallToolResult => {
111
+ if (isContentArray(result)) return { content: [...result] };
112
+ if (isCallToolResult(result)) return result;
113
+ return { content: [result] };
114
+ };
115
+
116
+ const withCors = (res: import('node:http').ServerResponse, origin: string | undefined) => {
117
+ if (!origin) return;
118
+ res.setHeader('Access-Control-Allow-Origin', origin);
119
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
120
+ res.setHeader(
121
+ 'Access-Control-Allow-Headers',
122
+ 'Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id, X-API-Key'
123
+ );
124
+ };
125
+
126
+ const isApiKeyValid = (
127
+ configuredKey: string,
128
+ presented: string | string[] | undefined
129
+ ): boolean => {
130
+ const provided = Array.isArray(presented) ? presented[0] : presented;
131
+ if (typeof provided !== 'string' || provided.length === 0) return false;
132
+ const expected = createHash('sha256').update(configuredKey).digest();
133
+ const actual = createHash('sha256').update(provided).digest();
134
+ return timingSafeEqual(expected, actual);
135
+ };
136
+
137
+ const unauthorized = (res: import('node:http').ServerResponse) => {
138
+ res.writeHead(401, { 'Content-Type': 'application/json', 'WWW-Authenticate': 'X-API-Key' });
139
+ res.end(
140
+ JSON.stringify({
141
+ jsonrpc: '2.0',
142
+ id: null,
143
+ error: { code: -32001, message: 'Unauthorized: missing or invalid X-API-Key header' },
144
+ })
145
+ );
146
+ };
147
+
148
+ const ensureStreamableAcceptHeader = (req: import('node:http').IncomingMessage) => {
149
+ const accept = req.headers.accept;
150
+ const acceptValues = Array.isArray(accept) ? accept.join(', ') : (accept ?? '');
151
+ if (acceptValues.includes('application/json') && acceptValues.includes('text/event-stream')) {
152
+ return;
153
+ }
154
+ req.headers.accept = 'application/json, text/event-stream';
155
+
156
+ const rawAcceptIndex = req.rawHeaders.findIndex(
157
+ (value, index) => index % 2 === 0 && value.toLowerCase() === 'accept'
158
+ );
159
+ if (rawAcceptIndex >= 0) {
160
+ req.rawHeaders[rawAcceptIndex + 1] = 'application/json, text/event-stream';
161
+ return;
162
+ }
163
+ req.rawHeaders.push('Accept', 'application/json, text/event-stream');
164
+ };
165
+
166
+ const handleNonMcpRoute = (
167
+ req: import('node:http').IncomingMessage,
168
+ res: import('node:http').ServerResponse,
169
+ pathname: string,
170
+ transportConfig: HttpTransportConfig
171
+ ): boolean => {
172
+ if (pathname === '/mcp/health' && req.method === 'GET') {
173
+ res.writeHead(200, { 'Content-Type': 'application/json' });
174
+ res.end(JSON.stringify({ status: 'ok' }));
175
+ return true;
176
+ }
177
+
178
+ if (pathname !== '/mcp') {
179
+ res.writeHead(404, { 'Content-Type': 'application/json' });
180
+ res.end(JSON.stringify({ error: 'Not found' }));
181
+ return true;
182
+ }
183
+
184
+ if (req.method === 'OPTIONS') {
185
+ res.writeHead(204);
186
+ res.end();
187
+ return true;
188
+ }
189
+
190
+ if (
191
+ transportConfig.apiKey !== undefined &&
192
+ !isApiKeyValid(transportConfig.apiKey, req.headers['x-api-key'])
193
+ ) {
194
+ unauthorized(res);
195
+ return true;
196
+ }
197
+
198
+ return false;
199
+ };
200
+
201
+ const handleHttpRequest = async (
202
+ req: import('node:http').IncomingMessage,
203
+ res: import('node:http').ServerResponse,
204
+ mcpServer: McpServer,
205
+ transportConfig: HttpTransportConfig
206
+ ): Promise<void> => {
207
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? transportConfig.hostname}`);
208
+
209
+ withCors(res, transportConfig.cors);
210
+
211
+ if (handleNonMcpRoute(req, res, url.pathname, transportConfig)) return;
212
+
213
+ ensureStreamableAcceptHeader(req);
214
+ const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
215
+
216
+ try {
217
+ await mcpServer.connect(transport as unknown as Parameters<McpServer['connect']>[0]);
218
+ await transport.handleRequest(req, res);
219
+ } finally {
220
+ await mcpServer.close();
221
+ }
222
+ };
223
+
224
+ const startHttpServer = async (
225
+ serverInfo: Pick<CreateServerOptions, 'name' | 'version' | 'instructions' | 'tools'>,
226
+ transportConfig: HttpTransportConfig
227
+ ): Promise<{ mcpServer: McpServer; httpServer: HttpServer }> => {
228
+ const mcpServer = buildMcpServer(serverInfo);
229
+ const httpServer = createHttpServer((req, res) => {
230
+ void handleHttpRequest(req, res, mcpServer, transportConfig);
231
+ });
232
+
233
+ await new Promise<void>((resolve, reject) => {
234
+ httpServer.once('error', reject);
235
+ httpServer.listen(transportConfig.port, transportConfig.hostname, () => {
236
+ httpServer.off('error', reject);
237
+ resolve();
238
+ });
239
+ });
240
+
241
+ return { mcpServer, httpServer };
242
+ };
243
+
244
+ const buildMcpServer = ({
245
+ name,
246
+ version,
247
+ instructions,
248
+ tools,
249
+ }: Pick<CreateServerOptions, 'name' | 'version' | 'instructions' | 'tools'>): McpServer => {
250
+ const mcpServer = new McpServer(
251
+ { name, version },
252
+ {
253
+ ...(instructions ? { instructions } : {}),
254
+ }
255
+ );
256
+
257
+ for (const [toolName, definition] of Object.entries(tools)) {
258
+ mcpServer.registerTool(
259
+ toolName,
260
+ {
261
+ description: definition.description,
262
+ inputSchema: definition.inputSchema,
263
+ },
264
+ async (input, ctx) => normalizeToolResult(await definition.handler({ input, ctx }))
265
+ );
266
+ }
267
+
268
+ return mcpServer;
269
+ };
270
+
271
+ export const createServer = (options: CreateServerOptions): ServerHandle => {
272
+ let mcpServer: McpServer | undefined;
273
+ let httpServer: HttpServer | undefined;
274
+
275
+ return {
276
+ async start() {
277
+ if (options.transport.kind === 'stdio') {
278
+ mcpServer = buildMcpServer(options);
279
+ await mcpServer.connect(new StdioServerTransport());
280
+ return;
281
+ }
282
+
283
+ const started = await startHttpServer(options, options.transport);
284
+ mcpServer = started.mcpServer;
285
+ httpServer = started.httpServer;
286
+ },
287
+ async close() {
288
+ await mcpServer?.close();
289
+ const serverToClose = httpServer;
290
+ if (!serverToClose) return;
291
+ await new Promise<void>((resolve, reject) => {
292
+ serverToClose.close((error) => {
293
+ if (error) {
294
+ reject(error);
295
+ return;
296
+ }
297
+ resolve();
298
+ });
299
+ });
300
+ },
301
+ };
302
+ };
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+
3
+ export const videoSourceSchema = z.object({
4
+ path: z.string().min(1).describe('Path to the local video file (absolute or relative to cwd).'),
5
+ });
6
+
7
+ export const readVideoArgsSchema = z.object({
8
+ sources: z.array(videoSourceSchema).min(1).describe('One or more local video sources to read.'),
9
+ include_streams: z
10
+ .boolean()
11
+ .optional()
12
+ .describe('Include stream metadata from ffprobe. Defaults to true.'),
13
+ include_chapters: z
14
+ .boolean()
15
+ .optional()
16
+ .describe('Include chapter markers when present. Defaults to true.'),
17
+ include_subtitles: z
18
+ .boolean()
19
+ .optional()
20
+ .describe('Extract embedded subtitles when available. Defaults to true.'),
21
+ include_scenes: z
22
+ .boolean()
23
+ .optional()
24
+ .describe('Detect scene boundaries with ffmpeg scene filter. Defaults to true.'),
25
+ scene_threshold: z
26
+ .number()
27
+ .min(0)
28
+ .max(1)
29
+ .optional()
30
+ .describe('Scene detection sensitivity for ffmpeg gt(scene,threshold). Defaults to 0.4.'),
31
+ include_transcript: z
32
+ .boolean()
33
+ .optional()
34
+ .describe(
35
+ 'Attempt optional local ASR transcript when an adapter is installed. Defaults to false.'
36
+ ),
37
+ include_keyframes: z
38
+ .boolean()
39
+ .optional()
40
+ .describe(
41
+ 'Index I-frame timestamps with ffmpeg for reproducible frame evidence follow-up. Defaults to false.'
42
+ ),
43
+ keyframe_limit: z
44
+ .number()
45
+ .int()
46
+ .min(1)
47
+ .max(64)
48
+ .optional()
49
+ .describe(
50
+ 'Maximum number of keyframe locators to return when include_keyframes is true. Defaults to 8.'
51
+ ),
52
+ include_keyframe_images: z
53
+ .boolean()
54
+ .optional()
55
+ .describe(
56
+ 'When include_keyframes is true, render citeable PNG thumbnails for each keyframe. Defaults to false.'
57
+ ),
58
+ keyframe_max_dimension: z
59
+ .number()
60
+ .int()
61
+ .positive()
62
+ .optional()
63
+ .describe('Maximum width or height when resizing keyframe PNG evidence.'),
64
+ });
65
+
66
+ export type ReadVideoArgs = z.infer<typeof readVideoArgsSchema>;
67
+ export type VideoSource = z.infer<typeof videoSourceSchema>;
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import { videoSourceSchema } from './readVideo.js';
3
+
4
+ const cropRegionSchema = z.object({
5
+ x: z.number().int().min(0).describe('Crop origin X in video pixel coordinates.'),
6
+ y: z.number().int().min(0).describe('Crop origin Y in video pixel coordinates.'),
7
+ width: z.number().int().positive().describe('Crop width in video pixel coordinates.'),
8
+ height: z.number().int().positive().describe('Crop height in video pixel coordinates.'),
9
+ });
10
+
11
+ const evidenceSourceSchema = videoSourceSchema.extend({
12
+ time_ms: z
13
+ .number()
14
+ .int()
15
+ .min(0)
16
+ .describe('Timestamp in milliseconds for frame evidence follow-up.'),
17
+ crop: cropRegionSchema
18
+ .optional()
19
+ .describe('Required for crop_frame; pixel crop bounds on the source video frame.'),
20
+ });
21
+
22
+ export const videoEvidenceArgsSchema = z
23
+ .object({
24
+ operation: z
25
+ .enum(['render_frame', 'crop_frame', 'ocr_frame'])
26
+ .describe('Focused frame evidence operation after read_video timeline discovery.'),
27
+ sources: z
28
+ .array(evidenceSourceSchema)
29
+ .min(1)
30
+ .describe('One or more local video sources with timestamp locators.'),
31
+ max_dimension: z
32
+ .number()
33
+ .int()
34
+ .positive()
35
+ .optional()
36
+ .describe('Maximum width or height when resizing rendered PNG evidence.'),
37
+ })
38
+ .superRefine((value, ctx) => {
39
+ if (value.operation === 'crop_frame') {
40
+ for (const [index, source] of value.sources.entries()) {
41
+ if (!source.crop) {
42
+ ctx.addIssue({
43
+ code: z.ZodIssueCode.custom,
44
+ message: 'crop_frame requires sources[].crop for each source.',
45
+ path: ['sources', index, 'crop'],
46
+ });
47
+ }
48
+ }
49
+ }
50
+ });
51
+
52
+ export type VideoEvidenceArgs = z.infer<typeof videoEvidenceArgsSchema>;
53
+ export type VideoEvidenceSource = z.infer<typeof evidenceSourceSchema>;
package/src/sdk.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Cue SDK — programmatic video timeline API (Sylphx).
3
+ * Isomorphic with MCP tools `read_video` / `video_evidence`.
4
+ */
5
+ import { readVideo } from './handlers/readVideo.js';
6
+ import { videoEvidence } from './handlers/videoEvidence.js';
7
+ import { type ReadVideoArgs, readVideoArgsSchema } from './schemas/readVideo.js';
8
+
9
+ export type CueReadInput = ReadVideoArgs | { path: string; [key: string]: unknown };
10
+
11
+ export { readVideoArgsSchema };
12
+
13
+ function normalizeReadInput(input: CueReadInput): ReadVideoArgs {
14
+ if ('sources' in input && Array.isArray((input as ReadVideoArgs).sources)) {
15
+ return readVideoArgsSchema.parse(input);
16
+ }
17
+ const { path, ...rest } = input as { path: string; [key: string]: unknown };
18
+ if (!path || typeof path !== 'string') {
19
+ throw new Error('Cue.read requires sources[] or path');
20
+ }
21
+ return readVideoArgsSchema.parse({
22
+ ...rest,
23
+ sources: [{ path }],
24
+ });
25
+ }
26
+
27
+ export class Cue {
28
+ static create(): Cue {
29
+ return new Cue();
30
+ }
31
+
32
+ /** MCP: read_video — accepts `{ sources:[{path}] }` or ergonomic `{ path }` */
33
+ async read(input: CueReadInput) {
34
+ const parsed = normalizeReadInput(input);
35
+ return readVideo.handler({ input: parsed, ctx: {} });
36
+ }
37
+
38
+ /** MCP: video_evidence */
39
+ async evidence(input: Record<string, unknown>) {
40
+ return videoEvidence.handler({ input: input as never, ctx: {} });
41
+ }
42
+ }
43
+
44
+ export default Cue;