@sylphx/video-reader-mcp 0.1.1 → 0.1.3

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 CHANGED
@@ -36,6 +36,15 @@ 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
+
46
+ **Local-first frontier:** ffmpeg/ffprobe + structural keyframes, optional local whisper ASR. No cloud required.
47
+
39
48
  ## Product docs
40
49
 
41
50
  | Doc | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/video-reader-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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. Returns a timeline document with ffprobe metadata, embedded subtitles, optional scene boundaries, and warnings no per-frame vision LLM.'
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 }) => {
@@ -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>;
@@ -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
+ }
package/src/utils/asr.ts CHANGED
@@ -1,6 +1,10 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
1
5
  import { shouldUseRustAsrEngine, transcribeViaRustEngine } from '../engine/rust-asr.js';
2
6
  import type { TranscriptSegment } from '../types/timeline.js';
3
- import { isBinaryAvailable } from './exec.js';
7
+ import { execBinary, isBinaryAvailable } from './exec.js';
4
8
 
5
9
  const ASR_CANDIDATES = ['whisper-cli', 'whisper-cpp', 'whisper', 'vosk-transcriber'] as const;
6
10
 
@@ -13,6 +17,124 @@ export const detectAsrAdapter = async (): Promise<string | null> => {
13
17
  return null;
14
18
  };
15
19
 
20
+ /** Parse whisper.txt style lines: [00:00.000 --> 00:01.000] text */
21
+ export function parseWhisperTxt(raw: string): TranscriptSegment[] {
22
+ const segments: TranscriptSegment[] = [];
23
+ const re =
24
+ /\[(\d{1,2}:)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\s*-->\s*(\d{1,2}:)?(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\]\s*(.+)/g;
25
+ const toMs = (h: string | undefined, min: string, sec: string, ms: string | undefined) => {
26
+ const hours = h ? Number.parseInt(h.replace(':', ''), 10) : 0;
27
+ const minutes = Number.parseInt(min, 10);
28
+ const seconds = Number.parseInt(sec, 10);
29
+ const millis = ms ? Number.parseInt(ms.padEnd(3, '0').slice(0, 3), 10) : 0;
30
+ return ((hours * 60 + minutes) * 60 + seconds) * 1000 + millis;
31
+ };
32
+ let m = re.exec(raw);
33
+ while (m !== null) {
34
+ const start = toMs(m[1], m[2] ?? '0', m[3] ?? '0', m[4]);
35
+ const end = toMs(m[5], m[6] ?? '0', m[7] ?? '0', m[8]);
36
+ const text = (m[9] ?? '').trim();
37
+ if (text) {
38
+ segments.push({
39
+ start_ms: start,
40
+ end_ms: end,
41
+ text,
42
+ provenance: { method: 'asr_adapter', adapter: 'whisper-cli' },
43
+ });
44
+ }
45
+ m = re.exec(raw);
46
+ }
47
+ if (segments.length === 0) {
48
+ const lines = raw
49
+ .split(/\r?\n/)
50
+ .map((l) => l.trim())
51
+ .filter((l) => l && !l.startsWith('['));
52
+ lines.forEach((text, i) => {
53
+ segments.push({
54
+ start_ms: i * 1000,
55
+ end_ms: (i + 1) * 1000,
56
+ text,
57
+ provenance: { method: 'asr_adapter', adapter: 'whisper-cli' },
58
+ });
59
+ });
60
+ }
61
+ return segments;
62
+ }
63
+
64
+ async function extractWavMono16k(
65
+ videoPath: string
66
+ ): Promise<{ wavPath: string; dir: string } | { error: string }> {
67
+ const ffmpegOk = await isBinaryAvailable('ffmpeg');
68
+ if (!ffmpegOk) {
69
+ return { error: 'ffmpeg required to extract audio for local whisper ASR' };
70
+ }
71
+ const dir = mkdtempSync(join(tmpdir(), 'cue-asr-'));
72
+ const wavPath = join(dir, 'audio.wav');
73
+ try {
74
+ await execBinary(
75
+ 'ffmpeg',
76
+ ['-hide_banner', '-y', '-i', videoPath, '-ac', '1', '-ar', '16000', '-f', 'wav', wavPath],
77
+ { timeoutMs: 300_000 }
78
+ );
79
+ return { wavPath, dir };
80
+ } catch (error: unknown) {
81
+ rmSync(dir, { recursive: true, force: true });
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ return { error: message };
84
+ }
85
+ }
86
+
87
+ /** Local-first frontier ASR: whisper-cli/whisper.cpp on PATH (no cloud). */
88
+ export async function runLocalWhisperTranscript(
89
+ videoPath: string,
90
+ adapter: string
91
+ ): Promise<{ transcript: TranscriptSegment[]; warning?: string }> {
92
+ const wav = await extractWavMono16k(videoPath);
93
+ if ('error' in wav) {
94
+ return { transcript: [], warning: `ASR audio extract failed: ${wav.error}` };
95
+ }
96
+
97
+ const outBase = join(wav.dir, 'out');
98
+ const model = process.env.CUE_WHISPER_MODEL ?? process.env.WHISPER_MODEL ?? '';
99
+ const args: string[] = ['-f', wav.wavPath, '-otxt', '-of', outBase];
100
+ if (model) args.push('-m', model);
101
+
102
+ try {
103
+ const result = spawnSync(adapter, args, {
104
+ encoding: 'utf8',
105
+ timeout: 600_000,
106
+ windowsHide: true,
107
+ maxBuffer: 20 * 1024 * 1024,
108
+ });
109
+ if (result.status !== 0) {
110
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
111
+ return {
112
+ transcript: [],
113
+ warning: `Local ASR (${adapter}) failed: ${stderr || `exit ${String(result.status)}`}`,
114
+ };
115
+ }
116
+ let raw = '';
117
+ try {
118
+ raw = readFileSync(`${outBase}.txt`, 'utf8');
119
+ } catch {
120
+ raw = typeof result.stdout === 'string' ? result.stdout : '';
121
+ }
122
+ const transcript = parseWhisperTxt(raw).map((seg) => ({
123
+ ...seg,
124
+ provenance: { method: 'asr_adapter' as const, adapter },
125
+ }));
126
+ if (transcript.length === 0) {
127
+ return { transcript: [], warning: `Local ASR (${adapter}) produced empty transcript` };
128
+ }
129
+ return { transcript };
130
+ } catch (error: unknown) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ return { transcript: [], warning: `Local ASR (${adapter}) error: ${message}` };
133
+ } finally {
134
+ rmSync(wav.dir, { recursive: true, force: true });
135
+ }
136
+ }
137
+
16
138
  export const tryAsrTranscript = async (
17
139
  videoPath: string,
18
140
  enabled: boolean
@@ -29,18 +151,12 @@ export const tryAsrTranscript = async (
29
151
  ...(response.result.warning ? { warning: response.result.warning } : {}),
30
152
  };
31
153
  }
32
-
33
- if (response.code === 'ADAPTER_UNAVAILABLE') {
154
+ if (response.code !== 'ADAPTER_UNAVAILABLE') {
34
155
  return {
35
156
  transcript: [],
36
- warning: `${response.message} Checked whisper-cli and whisper-cpp.`,
157
+ warning: `ASR transcription failed: ${response.message}`,
37
158
  };
38
159
  }
39
-
40
- return {
41
- transcript: [],
42
- warning: `ASR transcription failed: ${response.message}`,
43
- };
44
160
  }
45
161
 
46
162
  const adapter = await detectAsrAdapter();
@@ -48,12 +164,16 @@ export const tryAsrTranscript = async (
48
164
  return {
49
165
  transcript: [],
50
166
  warning:
51
- 'ASR requested but no local adapter found (checked whisper-cli, whisper-cpp, whisper, vosk-transcriber); transcript skipped.',
167
+ 'ASR requested but no local adapter found (checked whisper-cli, whisper-cpp, whisper, vosk-transcriber). Install whisper.cpp for local-first frontier ASR.',
52
168
  };
53
169
  }
54
170
 
171
+ if (adapter === 'whisper-cli' || adapter === 'whisper-cpp' || adapter === 'whisper') {
172
+ return runLocalWhisperTranscript(videoPath, adapter);
173
+ }
174
+
55
175
  return {
56
176
  transcript: [],
57
- warning: `ASR adapter "${adapter}" detected but Rust ASR engine is not enabled. Build video-reader-cli or set VIDEO_READER_USE_RUST_ASR=1.`,
177
+ warning: `ASR adapter "${adapter}" detected but no local runner implemented for it yet.`,
58
178
  };
59
179
  };
@@ -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: includeKeyframeImages,
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
- return {
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 (