@sylphx/video-reader-mcp 0.1.1 → 0.1.2
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/README.md +7 -0
- package/package.json +1 -1
- package/src/handlers/readVideo.ts +1 -1
- package/src/schemas/readVideo.ts +12 -0
- package/src/types/timeline.ts +13 -0
- package/src/utils/agentIndex.ts +107 -0
- package/src/utils/structuralKeyframes.ts +73 -0
- package/src/video/readCoordinator.ts +80 -5
package/README.md
CHANGED
|
@@ -36,6 +36,13 @@ Each instrument is an independent repository (marketplace + stars).
|
|
|
36
36
|
---
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
## Read video structure (not N-second frame spam)
|
|
41
|
+
|
|
42
|
+
Cue is **local-first timeline architecture**: streams, dialogue, scene cuts, **structural keyframes**, and **agent_index** for text-only agents.
|
|
43
|
+
|
|
44
|
+
Spec: [docs/specs/agent-video-read-contract.md](docs/specs/agent-video-read-contract.md)
|
|
45
|
+
|
|
39
46
|
## Product docs
|
|
40
47
|
|
|
41
48
|
| Doc | Purpose |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/video-reader-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"mcpName": "io.github.SylphxAI/video-reader-mcp",
|
|
5
5
|
"description": "Cue \u2014 Evidence-first video reading for AI agents \u2014 ffprobe, subtitles, scenes, transcripts, and timelines without frame-by-frame LLM vision.",
|
|
6
6
|
"type": "module",
|
|
@@ -7,7 +7,7 @@ const MAX_CONCURRENT_SOURCES = 2;
|
|
|
7
7
|
export const createReadVideoHandler = (version: string) =>
|
|
8
8
|
tool()
|
|
9
9
|
.description(
|
|
10
|
-
'Primary video reader
|
|
10
|
+
'Primary video reader for agents (read film structure, not sample frames blindly). Timeline: streams, scenes, subtitles, optional ASR, structural keyframe locators, agent_index outline. Local-first; no required vision LLM.'
|
|
11
11
|
)
|
|
12
12
|
.input(readVideoArgsSchema)
|
|
13
13
|
.handler(async ({ input }: { input: ReadVideoArgs }) => {
|
package/src/schemas/readVideo.ts
CHANGED
|
@@ -61,6 +61,18 @@ export const readVideoArgsSchema = z.object({
|
|
|
61
61
|
.positive()
|
|
62
62
|
.optional()
|
|
63
63
|
.describe('Maximum width or height when resizing keyframe PNG evidence.'),
|
|
64
|
+
keyframe_policy: z
|
|
65
|
+
.enum(['structural', 'iframes'])
|
|
66
|
+
.optional()
|
|
67
|
+
.describe(
|
|
68
|
+
'structural (default when include_keyframes): scene starts/midpoints. iframes: raw I-frame index only.'
|
|
69
|
+
),
|
|
70
|
+
include_agent_index: z
|
|
71
|
+
.boolean()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe(
|
|
74
|
+
'Return agent_index outline so text-only agents can read film structure. Defaults to true.'
|
|
75
|
+
),
|
|
64
76
|
});
|
|
65
77
|
|
|
66
78
|
export type ReadVideoArgs = z.infer<typeof readVideoArgsSchema>;
|
package/src/types/timeline.ts
CHANGED
|
@@ -96,6 +96,19 @@ export interface TimelineDocument {
|
|
|
96
96
|
transcript: TranscriptSegment[];
|
|
97
97
|
keyframes: FrameEvidence[];
|
|
98
98
|
warnings: string[];
|
|
99
|
+
/** Text outline of film architecture for non-vision agents. */
|
|
100
|
+
agent_index?: {
|
|
101
|
+
policy: string;
|
|
102
|
+
source: string;
|
|
103
|
+
duration_ms?: number;
|
|
104
|
+
outline: string;
|
|
105
|
+
scene_count: number;
|
|
106
|
+
keyframe_count: number;
|
|
107
|
+
subtitle_cue_count: number;
|
|
108
|
+
transcript_segment_count: number;
|
|
109
|
+
audio_stream_count: number;
|
|
110
|
+
video_stream_count: number;
|
|
111
|
+
};
|
|
99
112
|
}
|
|
100
113
|
|
|
101
114
|
export interface VideoSourceResult {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Index: a text-first reformatting of video structure.
|
|
3
|
+
* Agents "read" the film (timeline architecture), not sample arbitrary frames.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type AgentVideoIndex = {
|
|
7
|
+
policy: 'agent_video_index_v1';
|
|
8
|
+
source: string;
|
|
9
|
+
duration_ms?: number;
|
|
10
|
+
outline: string;
|
|
11
|
+
scene_count: number;
|
|
12
|
+
keyframe_count: number;
|
|
13
|
+
subtitle_cue_count: number;
|
|
14
|
+
transcript_segment_count: number;
|
|
15
|
+
audio_stream_count: number;
|
|
16
|
+
video_stream_count: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function buildAgentVideoIndex(doc: {
|
|
20
|
+
provenance: { source: string };
|
|
21
|
+
format?: { duration_ms?: number; format_name?: string };
|
|
22
|
+
streams?: Array<{ codec_type?: string; codec_name?: string; width?: number; height?: number }>;
|
|
23
|
+
scenes?: Array<{ time_ms?: number }>;
|
|
24
|
+
keyframes?: Array<{ time_ms?: number; frame_hash?: string }>;
|
|
25
|
+
subtitles?: Array<{ cues?: unknown[]; text?: string }>;
|
|
26
|
+
transcript?: Array<{ text?: string; start_ms?: number }>;
|
|
27
|
+
warnings?: string[];
|
|
28
|
+
}): AgentVideoIndex {
|
|
29
|
+
const streams = doc.streams ?? [];
|
|
30
|
+
const audio = streams.filter((s) => s.codec_type === 'audio');
|
|
31
|
+
const video = streams.filter((s) => s.codec_type === 'video');
|
|
32
|
+
const scenes = doc.scenes ?? [];
|
|
33
|
+
const keyframes = doc.keyframes ?? [];
|
|
34
|
+
const subtitleCueCount = (doc.subtitles ?? []).reduce((n, s) => {
|
|
35
|
+
if (Array.isArray(s.cues)) return n + s.cues.length;
|
|
36
|
+
return n + (s.text ? 1 : 0);
|
|
37
|
+
}, 0);
|
|
38
|
+
const transcript = doc.transcript ?? [];
|
|
39
|
+
const duration = doc.format?.duration_ms;
|
|
40
|
+
|
|
41
|
+
const lines: string[] = [
|
|
42
|
+
`# Video index: ${doc.provenance.source}`,
|
|
43
|
+
`- duration_ms: ${duration ?? 'unknown'}`,
|
|
44
|
+
`- container: ${doc.format?.format_name ?? 'unknown'}`,
|
|
45
|
+
`- video_streams: ${video.length}${video[0] ? ` (${video[0].width ?? '?'}x${video[0].height ?? '?'} ${video[0].codec_name ?? ''})` : ''}`,
|
|
46
|
+
`- audio_streams: ${audio.length}${audio[0] ? ` (${audio[0].codec_name ?? ''})` : ''}`,
|
|
47
|
+
`- scenes: ${scenes.length}`,
|
|
48
|
+
`- structural_keyframes: ${keyframes.length}`,
|
|
49
|
+
`- subtitle_cues: ${subtitleCueCount}`,
|
|
50
|
+
`- transcript_segments: ${transcript.length}`,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
if (scenes.length > 0) {
|
|
54
|
+
lines.push('', '## Scene architecture (not fixed-interval sampling)');
|
|
55
|
+
scenes.slice(0, 40).forEach((s, i) => {
|
|
56
|
+
lines.push(`- scene cut ${i + 1}: ${s.time_ms ?? '?'}ms`);
|
|
57
|
+
});
|
|
58
|
+
if (scenes.length > 40) lines.push(`- … ${scenes.length - 40} more scenes`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (keyframes.length > 0) {
|
|
62
|
+
lines.push('', '## Structural keyframe locators');
|
|
63
|
+
keyframes.slice(0, 32).forEach((k, i) => {
|
|
64
|
+
lines.push(
|
|
65
|
+
`- kf ${i + 1}: t=${k.time_ms ?? '?'}ms hash=${String(k.frame_hash ?? '').slice(0, 12) || 'n/a'}`
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (subtitleCueCount > 0) {
|
|
71
|
+
lines.push('', '## Subtitles present (embedded) — prefer as dialogue evidence over vision');
|
|
72
|
+
}
|
|
73
|
+
if (transcript.length > 0) {
|
|
74
|
+
lines.push('', '## Transcript segments (optional ASR)');
|
|
75
|
+
transcript.slice(0, 12).forEach((tseg) => {
|
|
76
|
+
const text = String(tseg.text ?? '')
|
|
77
|
+
.replace(/\s+/g, ' ')
|
|
78
|
+
.slice(0, 120);
|
|
79
|
+
lines.push(`- ${tseg.start_ms ?? '?'}ms: ${text}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if ((doc.warnings ?? []).length > 0) {
|
|
84
|
+
lines.push('', '## Warnings', ...(doc.warnings ?? []).map((w) => `- ${w}`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
lines.push(
|
|
88
|
+
'',
|
|
89
|
+
'## Agent guidance',
|
|
90
|
+
'- Use scenes + keyframe locators for structure; do not invent uniform N-second sampling as architecture.',
|
|
91
|
+
'- For pixel-level claims, follow up with video_evidence render_frame/crop_frame at a locator time_ms.',
|
|
92
|
+
'- Optional LLM vision is non-authority; timeline evidence is the local-first truth.'
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
policy: 'agent_video_index_v1',
|
|
97
|
+
source: doc.provenance.source,
|
|
98
|
+
...(duration !== undefined ? { duration_ms: duration } : {}),
|
|
99
|
+
outline: lines.join('\n'),
|
|
100
|
+
scene_count: scenes.length,
|
|
101
|
+
keyframe_count: keyframes.length,
|
|
102
|
+
subtitle_cue_count: subtitleCueCount,
|
|
103
|
+
transcript_segment_count: transcript.length,
|
|
104
|
+
audio_stream_count: audio.length,
|
|
105
|
+
video_stream_count: video.length,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural keyframe policy: prefer scene-change timestamps over fixed-interval thumbs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type StructuralKeyframePlan = {
|
|
6
|
+
policy: 'structural_v1' | 'iframes_v1';
|
|
7
|
+
times_ms: number[];
|
|
8
|
+
notes: string[];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function planStructuralKeyframeTimes(input: {
|
|
12
|
+
durationMs?: number;
|
|
13
|
+
sceneTimesMs: number[];
|
|
14
|
+
iframeTimesMs: number[];
|
|
15
|
+
limit: number;
|
|
16
|
+
}): StructuralKeyframePlan {
|
|
17
|
+
const notes: string[] = [];
|
|
18
|
+
const limit = Math.max(1, Math.min(64, input.limit));
|
|
19
|
+
const times = new Set<number>();
|
|
20
|
+
|
|
21
|
+
const sceneTimes = [...input.sceneTimesMs]
|
|
22
|
+
.filter((t) => Number.isFinite(t) && t >= 0)
|
|
23
|
+
.sort((a, b) => a - b);
|
|
24
|
+
if (sceneTimes.length > 0) {
|
|
25
|
+
times.add(0);
|
|
26
|
+
for (let i = 0; i < sceneTimes.length; i++) {
|
|
27
|
+
const st = sceneTimes[i];
|
|
28
|
+
if (st !== undefined) times.add(st);
|
|
29
|
+
const a = sceneTimes[i];
|
|
30
|
+
const b = sceneTimes[i + 1];
|
|
31
|
+
if (a !== undefined && b !== undefined) {
|
|
32
|
+
times.add(Math.round((a + b) / 2));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (typeof input.durationMs === 'number' && input.durationMs > 0) {
|
|
36
|
+
times.add(Math.max(0, input.durationMs - 1));
|
|
37
|
+
}
|
|
38
|
+
notes.push(
|
|
39
|
+
'Keyframe times derived from scene-change architecture (starts + mid-gaps), not fixed N-second sampling.'
|
|
40
|
+
);
|
|
41
|
+
const sorted = [...times].sort((a, b) => a - b);
|
|
42
|
+
if (sorted.length <= limit) {
|
|
43
|
+
return { policy: 'structural_v1', times_ms: sorted, notes };
|
|
44
|
+
}
|
|
45
|
+
const picked: number[] = [];
|
|
46
|
+
for (let i = 0; i < limit; i++) {
|
|
47
|
+
const idx = Math.round((i * (sorted.length - 1)) / Math.max(1, limit - 1));
|
|
48
|
+
const v = sorted[idx];
|
|
49
|
+
if (v !== undefined) picked.push(v);
|
|
50
|
+
}
|
|
51
|
+
notes.push(`Downsampled structural candidates from ${sorted.length} to ${limit}.`);
|
|
52
|
+
return {
|
|
53
|
+
policy: 'structural_v1',
|
|
54
|
+
times_ms: [...new Set(picked)].sort((a, b) => a - b),
|
|
55
|
+
notes,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
notes.push('No scene cuts available; falling back to I-frame timestamps.');
|
|
60
|
+
const iframes = input.iframeTimesMs.slice(0, limit);
|
|
61
|
+
if (iframes.length === 0 && typeof input.durationMs === 'number' && input.durationMs > 0) {
|
|
62
|
+
notes.push('No I-frames parsed; using start/mid/end anchors only.');
|
|
63
|
+
return {
|
|
64
|
+
policy: 'structural_v1',
|
|
65
|
+
times_ms: [0, Math.round(input.durationMs / 2), Math.max(0, input.durationMs - 1)].slice(
|
|
66
|
+
0,
|
|
67
|
+
limit
|
|
68
|
+
),
|
|
69
|
+
notes,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { policy: 'iframes_v1', times_ms: iframes, notes };
|
|
73
|
+
}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from '../engine/rust-timeline.js';
|
|
8
8
|
import type { ReadVideoArgs } from '../schemas/readVideo.js';
|
|
9
9
|
import type { TimelineDocument, VideoSourceResult } from '../types/timeline.js';
|
|
10
|
+
import { buildAgentVideoIndex } from '../utils/agentIndex.js';
|
|
10
11
|
import { tryAsrTranscript } from '../utils/asr.js';
|
|
11
12
|
import { extractSubtitles } from '../utils/ffmpeg.js';
|
|
12
13
|
import {
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
import { extractKeyframes } from '../utils/frames.js';
|
|
21
22
|
import { resolvePath } from '../utils/pathUtils.js';
|
|
22
23
|
import { detectScenes } from '../utils/scenes.js';
|
|
24
|
+
import { planStructuralKeyframeTimes } from '../utils/structuralKeyframes.js';
|
|
23
25
|
|
|
24
26
|
const DEFAULT_SCENE_THRESHOLD = 0.4;
|
|
25
27
|
const DEFAULT_KEYFRAME_LIMIT = 8;
|
|
@@ -109,19 +111,81 @@ export const buildTimelineDocument = async (
|
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
let keyframes: TimelineDocument['keyframes'] = [];
|
|
114
|
+
const keyframePolicy = args.keyframe_policy ?? 'structural';
|
|
112
115
|
if (includeKeyframes) {
|
|
113
|
-
const extracted = await extractKeyframes(sourcePath, keyframeLimit, {
|
|
114
|
-
includeImages:
|
|
116
|
+
const extracted = await extractKeyframes(sourcePath, Math.max(keyframeLimit, 32), {
|
|
117
|
+
includeImages: false,
|
|
115
118
|
...(keyframeMaxDimension !== undefined ? { maxDimension: keyframeMaxDimension } : {}),
|
|
116
119
|
});
|
|
117
|
-
keyframes = extracted.keyframes;
|
|
118
120
|
if (extracted.warning) warnings.push(extracted.warning);
|
|
121
|
+
|
|
122
|
+
if (keyframePolicy === 'iframes') {
|
|
123
|
+
keyframes = extracted.keyframes.slice(0, keyframeLimit);
|
|
124
|
+
if (includeKeyframeImages && extracted.keyframes.length > 0) {
|
|
125
|
+
const withImages = await extractKeyframes(sourcePath, keyframeLimit, {
|
|
126
|
+
includeImages: true,
|
|
127
|
+
...(keyframeMaxDimension !== undefined ? { maxDimension: keyframeMaxDimension } : {}),
|
|
128
|
+
});
|
|
129
|
+
keyframes = withImages.keyframes;
|
|
130
|
+
if (withImages.warning) warnings.push(withImages.warning);
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
const plan = planStructuralKeyframeTimes({
|
|
134
|
+
durationMs: format.duration_ms,
|
|
135
|
+
sceneTimesMs: scenes.map((s) => s.time_ms),
|
|
136
|
+
iframeTimesMs: extracted.keyframes.map((k) => k.time_ms),
|
|
137
|
+
limit: keyframeLimit,
|
|
138
|
+
});
|
|
139
|
+
warnings.push(...plan.notes);
|
|
140
|
+
keyframes = plan.times_ms.map((time_ms, index) => {
|
|
141
|
+
const nearest = extracted.keyframes.reduce<(typeof extracted.keyframes)[number] | null>(
|
|
142
|
+
(best, kf) => {
|
|
143
|
+
if (!best) return kf;
|
|
144
|
+
return Math.abs(kf.time_ms - time_ms) < Math.abs(best.time_ms - time_ms) ? kf : best;
|
|
145
|
+
},
|
|
146
|
+
null
|
|
147
|
+
);
|
|
148
|
+
return {
|
|
149
|
+
index,
|
|
150
|
+
time_ms,
|
|
151
|
+
provenance: {
|
|
152
|
+
method: 'ffmpeg_keyframe_select' as const,
|
|
153
|
+
pict_type: 'I' as const,
|
|
154
|
+
},
|
|
155
|
+
...(nearest?.frame_hash ? { frame_hash: nearest.frame_hash } : {}),
|
|
156
|
+
...(nearest?.route ? { route: nearest.route } : {}),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
// Optional images still use iframe extractor for true pixel evidence when requested.
|
|
160
|
+
if (includeKeyframeImages) {
|
|
161
|
+
const withImages = await extractKeyframes(sourcePath, keyframeLimit, {
|
|
162
|
+
includeImages: true,
|
|
163
|
+
...(keyframeMaxDimension !== undefined ? { maxDimension: keyframeMaxDimension } : {}),
|
|
164
|
+
});
|
|
165
|
+
if (withImages.warning) warnings.push(withImages.warning);
|
|
166
|
+
// attach images from nearest iframe evidence when hashes/times align
|
|
167
|
+
keyframes = keyframes.map((kf) => {
|
|
168
|
+
const hit = withImages.keyframes.find((x) => Math.abs(x.time_ms - kf.time_ms) < 50);
|
|
169
|
+
if (!hit) return kf;
|
|
170
|
+
return {
|
|
171
|
+
...kf,
|
|
172
|
+
...(hit.frame_hash ? { frame_hash: hit.frame_hash } : {}),
|
|
173
|
+
...(hit.image_base64 ? { image_base64: hit.image_base64 } : {}),
|
|
174
|
+
...(hit.mime ? { mime: hit.mime } : {}),
|
|
175
|
+
...(hit.width ? { width: hit.width } : {}),
|
|
176
|
+
...(hit.height ? { height: hit.height } : {}),
|
|
177
|
+
...(hit.route ? { route: hit.route } : {}),
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
119
182
|
}
|
|
120
183
|
|
|
121
|
-
|
|
184
|
+
const includeAgentIndex = args.include_agent_index ?? true;
|
|
185
|
+
const documentBase = {
|
|
122
186
|
provenance: {
|
|
123
187
|
source: sourcePath,
|
|
124
|
-
tool: 'read_video',
|
|
188
|
+
tool: 'read_video' as const,
|
|
125
189
|
version,
|
|
126
190
|
extracted_at: new Date().toISOString(),
|
|
127
191
|
...(sourceHash ? { source_hash: sourceHash } : {}),
|
|
@@ -137,6 +201,17 @@ export const buildTimelineDocument = async (
|
|
|
137
201
|
keyframes,
|
|
138
202
|
warnings,
|
|
139
203
|
};
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
...documentBase,
|
|
207
|
+
...(includeAgentIndex
|
|
208
|
+
? {
|
|
209
|
+
agent_index: buildAgentVideoIndex(
|
|
210
|
+
documentBase as Parameters<typeof buildAgentVideoIndex>[0]
|
|
211
|
+
),
|
|
212
|
+
}
|
|
213
|
+
: {}),
|
|
214
|
+
};
|
|
140
215
|
};
|
|
141
216
|
|
|
142
217
|
export const processVideoSource = async (
|