@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 +9 -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/asr.ts +131 -11
- package/src/utils/structuralKeyframes.ts +73 -0
- package/src/video/readCoordinator.ts +80 -5
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.
|
|
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
|
|
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
|
+
}
|
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:
|
|
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)
|
|
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
|
|
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:
|
|
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 (
|