mcp-video-analyzer 0.2.3 → 0.2.5
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 +6 -0
- package/dist/processors/frame-ocr.d.ts +1 -1
- package/dist/processors/frame-ocr.js +5 -3
- package/dist/server.js +1 -1
- package/dist/tools/analyze-moment.js +19 -8
- package/dist/tools/analyze-video.js +22 -10
- package/dist/tools/get-frame-at.js +8 -7
- package/dist/tools/get-frame-burst.js +8 -6
- package/dist/tools/get-frames.js +7 -4
- package/dist/tools/get-metadata.js +5 -1
- package/dist/tools/get-transcript.js +8 -1
- package/dist/utils/progress.d.ts +12 -0
- package/dist/utils/progress.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# mcp-video-analyzer
|
|
2
2
|
|
|
3
|
+
<a href="https://glama.ai/mcp/servers/guimatheus92/mcp-video-analyzer">
|
|
4
|
+
<img width="380" height="200" src="https://glama.ai/mcp/servers/guimatheus92/mcp-video-analyzer/badge" alt="mcp-video-analyzer MCP server" />
|
|
5
|
+
</a>
|
|
6
|
+
|
|
7
|
+
Featured in [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers#-multimedia-process).
|
|
8
|
+
|
|
3
9
|
MCP server for video analysis — extracts transcripts, key frames, and metadata from video URLs. Supports Loom, direct video files (.mp4, .webm), and more.
|
|
4
10
|
|
|
5
11
|
No existing video MCP combines **transcripts + visual frames + metadata** in one tool. This one does.
|
|
@@ -10,4 +10,4 @@ export interface IOcrResult {
|
|
|
10
10
|
*
|
|
11
11
|
* Only includes results with meaningful text (confidence > 50%, text length > 3).
|
|
12
12
|
*/
|
|
13
|
-
export declare function extractTextFromFrames(frames: IFrameResult[]): Promise<IOcrResult[]>;
|
|
13
|
+
export declare function extractTextFromFrames(frames: IFrameResult[], language?: string, onProgress?: (completed: number, total: number) => void): Promise<IOcrResult[]>;
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Only includes results with meaningful text (confidence > 50%, text length > 3).
|
|
6
6
|
*/
|
|
7
|
-
export async function extractTextFromFrames(frames) {
|
|
7
|
+
export async function extractTextFromFrames(frames, language = 'eng+por', onProgress) {
|
|
8
8
|
const Tesseract = await loadTesseract();
|
|
9
9
|
if (!Tesseract)
|
|
10
10
|
return [];
|
|
11
|
-
const worker = await Tesseract.createWorker(
|
|
11
|
+
const worker = await Tesseract.createWorker(language);
|
|
12
12
|
try {
|
|
13
13
|
const results = [];
|
|
14
|
-
for (
|
|
14
|
+
for (let i = 0; i < frames.length; i++) {
|
|
15
|
+
const frame = frames[i];
|
|
15
16
|
try {
|
|
16
17
|
const { data: { text, confidence }, } = await worker.recognize(frame.filePath);
|
|
17
18
|
const cleaned = text.trim();
|
|
@@ -26,6 +27,7 @@ export async function extractTextFromFrames(frames) {
|
|
|
26
27
|
catch {
|
|
27
28
|
// Skip frames that fail OCR
|
|
28
29
|
}
|
|
30
|
+
onProgress?.(i + 1, frames.length);
|
|
29
31
|
}
|
|
30
32
|
return results;
|
|
31
33
|
}
|
package/dist/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { registerGetTranscript } from './tools/get-transcript.js';
|
|
|
12
12
|
export function createServer() {
|
|
13
13
|
const server = new FastMCP({
|
|
14
14
|
name: 'mcp-video-analyzer',
|
|
15
|
-
version: '0.2.
|
|
15
|
+
version: '0.2.5',
|
|
16
16
|
instructions: `Video analysis MCP server. Extracts transcripts, key frames, metadata, comments, OCR text, and annotated timelines from video URLs.
|
|
17
17
|
|
|
18
18
|
AUTOMATIC BEHAVIOR — Do NOT wait for the user to ask:
|
|
@@ -6,6 +6,7 @@ import { deduplicateFrames } from '../processors/frame-dedup.js';
|
|
|
6
6
|
import { extractFrameBurst, parseTimestamp } from '../processors/frame-extractor.js';
|
|
7
7
|
import { extractTextFromFrames } from '../processors/frame-ocr.js';
|
|
8
8
|
import { optimizeFrames } from '../processors/image-optimizer.js';
|
|
9
|
+
import { createProgressReporter } from '../utils/progress.js';
|
|
9
10
|
import { createTempDir } from '../utils/temp-files.js';
|
|
10
11
|
const AnalyzeMomentSchema = z.object({
|
|
11
12
|
url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
|
|
@@ -18,6 +19,10 @@ const AnalyzeMomentSchema = z.object({
|
|
|
18
19
|
.default(10)
|
|
19
20
|
.optional()
|
|
20
21
|
.describe('Number of frames to extract in the range (default: 10)'),
|
|
22
|
+
ocrLanguage: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Tesseract OCR language codes (default: "eng+por"). Use "+" to combine: "eng+spa", "eng+fra+deu".'),
|
|
21
26
|
});
|
|
22
27
|
export function registerAnalyzeMoment(server) {
|
|
23
28
|
server.addTool({
|
|
@@ -45,8 +50,10 @@ Requires video download capability for frame extraction.`,
|
|
|
45
50
|
openWorldHint: true,
|
|
46
51
|
},
|
|
47
52
|
execute: async (args, { reportProgress }) => {
|
|
53
|
+
const progress = createProgressReporter(reportProgress);
|
|
48
54
|
const { url, from, to } = args;
|
|
49
55
|
const count = args.count ?? 10;
|
|
56
|
+
const ocrLanguage = args.ocrLanguage ?? 'eng+por';
|
|
50
57
|
// Validate timestamps
|
|
51
58
|
const fromSeconds = parseTimestamp(from);
|
|
52
59
|
const toSeconds = parseTimestamp(to);
|
|
@@ -64,7 +71,7 @@ Requires video download capability for frame extraction.`,
|
|
|
64
71
|
}
|
|
65
72
|
const warnings = [];
|
|
66
73
|
const tempDir = await createTempDir();
|
|
67
|
-
await
|
|
74
|
+
await progress(0, `Starting moment analysis (${from} → ${to})...`);
|
|
68
75
|
// Fetch transcript and filter to time range
|
|
69
76
|
const fullTranscript = await adapter.getTranscript(url).catch((e) => {
|
|
70
77
|
warnings.push(`Failed to fetch transcript: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -74,7 +81,7 @@ Requires video download capability for frame extraction.`,
|
|
|
74
81
|
const entrySeconds = parseTimestampLoose(entry.time);
|
|
75
82
|
return entrySeconds !== null && entrySeconds >= fromSeconds && entrySeconds <= toSeconds;
|
|
76
83
|
});
|
|
77
|
-
await
|
|
84
|
+
await progress(15, 'Transcript filtered to time range');
|
|
78
85
|
// Download video and extract burst frames
|
|
79
86
|
if (!adapter.capabilities.videoDownload) {
|
|
80
87
|
throw new UserError('Moment analysis requires video download capability. Use a direct video URL (.mp4, .webm, .mov).');
|
|
@@ -83,9 +90,9 @@ Requires video download capability for frame extraction.`,
|
|
|
83
90
|
if (!videoPath) {
|
|
84
91
|
throw new UserError('Failed to download video for moment analysis.');
|
|
85
92
|
}
|
|
86
|
-
await
|
|
93
|
+
await progress(35, 'Video downloaded, extracting burst frames...');
|
|
87
94
|
const rawFrames = await extractFrameBurst(videoPath, tempDir, from, to, count);
|
|
88
|
-
await
|
|
95
|
+
await progress(55, `Extracted ${rawFrames.length} frames, optimizing...`);
|
|
89
96
|
// Optimize frames
|
|
90
97
|
const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
|
|
91
98
|
warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -101,16 +108,20 @@ Requires video download capability for frame extraction.`,
|
|
|
101
108
|
if (frames.length < beforeDedup) {
|
|
102
109
|
warnings.push(`Removed ${beforeDedup - frames.length} near-duplicate frames (${beforeDedup} → ${frames.length})`);
|
|
103
110
|
}
|
|
104
|
-
await
|
|
111
|
+
await progress(70, 'Filtering and deduplicating frames...');
|
|
105
112
|
// OCR
|
|
106
|
-
|
|
113
|
+
await progress(75, `Running OCR on ${frames.length} frames...`);
|
|
114
|
+
const ocrResults = await extractTextFromFrames(frames, ocrLanguage, (completed, total) => {
|
|
115
|
+
const pct = 75 + Math.round((completed / total) * 15);
|
|
116
|
+
progress(pct, `OCR: processing frame ${completed}/${total}...`);
|
|
117
|
+
}).catch((e) => {
|
|
107
118
|
warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
108
119
|
return [];
|
|
109
120
|
});
|
|
110
|
-
await
|
|
121
|
+
await progress(92, 'Building annotated timeline...');
|
|
111
122
|
// Build mini-timeline for this range
|
|
112
123
|
const timeline = buildAnnotatedTimeline(transcriptSegment, frames, ocrResults);
|
|
113
|
-
await
|
|
124
|
+
await progress(100, 'Moment analysis complete');
|
|
114
125
|
// Build response
|
|
115
126
|
const textData = {
|
|
116
127
|
range: { from, to, fromSeconds, toSeconds },
|
|
@@ -11,6 +11,7 @@ import { extractTextFromFrames } from '../processors/frame-ocr.js';
|
|
|
11
11
|
import { optimizeFrames } from '../processors/image-optimizer.js';
|
|
12
12
|
import { AnalysisCache, cacheKey } from '../utils/cache.js';
|
|
13
13
|
import { filterAnalysisResult } from '../utils/field-filter.js';
|
|
14
|
+
import { createProgressReporter } from '../utils/progress.js';
|
|
14
15
|
import { cleanupTempDir, createTempDir } from '../utils/temp-files.js';
|
|
15
16
|
const cache = new AnalysisCache();
|
|
16
17
|
const ANALYSIS_FIELDS = [
|
|
@@ -62,6 +63,10 @@ const AnalyzeOptionsSchema = z
|
|
|
62
63
|
.default(false)
|
|
63
64
|
.optional()
|
|
64
65
|
.describe('Bypass cache and re-analyze the video'),
|
|
66
|
+
ocrLanguage: z
|
|
67
|
+
.string()
|
|
68
|
+
.optional()
|
|
69
|
+
.describe('Tesseract OCR language codes (default: "eng+por"). Use "+" to combine: "eng+spa", "eng+fra+deu". See Tesseract docs for codes.'),
|
|
65
70
|
})
|
|
66
71
|
.optional();
|
|
67
72
|
const AnalyzeVideoSchema = z.object({
|
|
@@ -99,11 +104,13 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
99
104
|
openWorldHint: true,
|
|
100
105
|
},
|
|
101
106
|
execute: async (args, { reportProgress }) => {
|
|
107
|
+
const progress = createProgressReporter(reportProgress);
|
|
102
108
|
const { url, options } = args;
|
|
103
109
|
const detail = options?.detail ?? 'standard';
|
|
104
110
|
const forceRefresh = options?.forceRefresh ?? false;
|
|
105
111
|
const fields = options?.fields;
|
|
106
112
|
const threshold = options?.threshold ?? 0.1;
|
|
113
|
+
const ocrLanguage = options?.ocrLanguage ?? 'eng+por';
|
|
107
114
|
// Resolve detail config
|
|
108
115
|
const config = getDetailConfig(detail);
|
|
109
116
|
const maxFrames = options?.maxFrames ?? config.maxFrames;
|
|
@@ -142,7 +149,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
142
149
|
const warnings = [];
|
|
143
150
|
let tempDir = null;
|
|
144
151
|
try {
|
|
145
|
-
await
|
|
152
|
+
await progress(0, 'Starting video analysis...');
|
|
146
153
|
// Fetch metadata, transcript, comments in parallel
|
|
147
154
|
const [metadata, transcript, comments, chapters, aiSummary] = await Promise.all([
|
|
148
155
|
adapter.getMetadata(url).catch((e) => {
|
|
@@ -166,7 +173,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
166
173
|
adapter.getChapters(url).catch(() => []),
|
|
167
174
|
adapter.getAiSummary(url).catch(() => null),
|
|
168
175
|
]);
|
|
169
|
-
await
|
|
176
|
+
await progress(35, 'Metadata and transcript fetched');
|
|
170
177
|
// Apply transcript limit for brief mode
|
|
171
178
|
const limitedTranscript = config.transcriptMaxEntries !== null
|
|
172
179
|
? transcript.slice(0, config.transcriptMaxEntries)
|
|
@@ -191,7 +198,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
191
198
|
if (adapter.capabilities.videoDownload) {
|
|
192
199
|
videoPath = await adapter.downloadVideo(url, tempDir);
|
|
193
200
|
if (videoPath) {
|
|
194
|
-
await
|
|
201
|
+
await progress(50, 'Video downloaded, extracting frames...');
|
|
195
202
|
// Probe duration if metadata didn't provide it
|
|
196
203
|
if (metadata.duration === 0) {
|
|
197
204
|
const duration = await probeVideoDuration(videoPath).catch(() => 0);
|
|
@@ -211,7 +218,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
211
218
|
warnings.push(`Frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
212
219
|
return [];
|
|
213
220
|
});
|
|
214
|
-
await
|
|
221
|
+
await progress(70, `Extracted ${rawFrames.length} frames, optimizing...`);
|
|
215
222
|
if (rawFrames.length > 0) {
|
|
216
223
|
const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
|
|
217
224
|
warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -227,7 +234,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
227
234
|
}
|
|
228
235
|
// Strategy 2: Browser-based extraction (fallback)
|
|
229
236
|
if (!framesExtracted && metadata.duration > 0) {
|
|
230
|
-
await
|
|
237
|
+
await progress(50, 'Extracting frames via browser fallback...');
|
|
231
238
|
const timestamps = generateTimestamps(metadata.duration, maxFrames);
|
|
232
239
|
const browserFrames = await extractBrowserFrames(url, tempDir, {
|
|
233
240
|
timestamps,
|
|
@@ -235,7 +242,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
235
242
|
warnings.push(`Browser frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
236
243
|
return [];
|
|
237
244
|
});
|
|
238
|
-
await
|
|
245
|
+
await progress(70, `Browser extracted ${browserFrames.length} frames`);
|
|
239
246
|
if (browserFrames.length > 0) {
|
|
240
247
|
result.frames = browserFrames;
|
|
241
248
|
framesExtracted = true;
|
|
@@ -264,18 +271,23 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
264
271
|
if (result.frames.length < beforeDedup) {
|
|
265
272
|
warnings.push(`Removed ${beforeDedup - result.frames.length} near-duplicate frames (${beforeDedup} → ${result.frames.length})`);
|
|
266
273
|
}
|
|
267
|
-
await
|
|
274
|
+
await progress(80, 'Filtering and deduplicating frames...');
|
|
268
275
|
// OCR: extract text visible on screen
|
|
269
276
|
if (config.includeOcr) {
|
|
270
|
-
|
|
277
|
+
await progress(85, `Running OCR on ${result.frames.length} frames...`);
|
|
278
|
+
result.ocrResults = await extractTextFromFrames(result.frames, ocrLanguage, (completed, total) => {
|
|
279
|
+
const pct = 85 + Math.round((completed / total) * 8);
|
|
280
|
+
progress(pct, `OCR: processing frame ${completed}/${total}...`);
|
|
281
|
+
}).catch((e) => {
|
|
271
282
|
warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
272
283
|
return [];
|
|
273
284
|
});
|
|
274
285
|
}
|
|
275
|
-
await
|
|
286
|
+
await progress(93, 'OCR complete');
|
|
276
287
|
}
|
|
277
288
|
// Build annotated timeline
|
|
278
289
|
if (config.includeTimeline) {
|
|
290
|
+
await progress(95, 'Building annotated timeline...');
|
|
279
291
|
result.timeline = buildAnnotatedTimeline(result.transcript, result.frames, result.ocrResults);
|
|
280
292
|
}
|
|
281
293
|
}
|
|
@@ -300,7 +312,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
300
312
|
// Audio extraction or transcription failed — not critical
|
|
301
313
|
}
|
|
302
314
|
}
|
|
303
|
-
await
|
|
315
|
+
await progress(100, 'Analysis complete');
|
|
304
316
|
// Cache the full result
|
|
305
317
|
cache.set(key, result);
|
|
306
318
|
// Apply field filter
|
|
@@ -4,8 +4,8 @@ import { getAdapter } from '../adapters/adapter.interface.js';
|
|
|
4
4
|
import { extractBrowserFrames } from '../processors/browser-frame-extractor.js';
|
|
5
5
|
import { extractFrameAt, parseTimestamp } from '../processors/frame-extractor.js';
|
|
6
6
|
import { optimizeFrame } from '../processors/image-optimizer.js';
|
|
7
|
-
import {
|
|
8
|
-
import { getTempFilePath } from '../utils/temp-files.js';
|
|
7
|
+
import { createProgressReporter } from '../utils/progress.js';
|
|
8
|
+
import { createTempDir, getTempFilePath } from '../utils/temp-files.js';
|
|
9
9
|
const GetFrameAtSchema = z.object({
|
|
10
10
|
url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
|
|
11
11
|
timestamp: z
|
|
@@ -42,19 +42,20 @@ Returns: A single image of the video frame at the specified timestamp.`,
|
|
|
42
42
|
openWorldHint: true,
|
|
43
43
|
},
|
|
44
44
|
execute: async (args, { reportProgress }) => {
|
|
45
|
+
const progress = createProgressReporter(reportProgress);
|
|
45
46
|
const { url, timestamp } = args;
|
|
46
47
|
const adapter = getAdapter(url);
|
|
47
|
-
await
|
|
48
|
+
await progress(0, 'Starting frame extraction...');
|
|
48
49
|
const tempDir = await createTempDir();
|
|
49
50
|
// Strategy 1: Download video + ffmpeg extraction
|
|
50
51
|
if (adapter.capabilities.videoDownload) {
|
|
51
52
|
const videoPath = await adapter.downloadVideo(url, tempDir);
|
|
52
53
|
if (videoPath) {
|
|
53
|
-
await
|
|
54
|
+
await progress(50, `Extracting frame at ${timestamp}...`);
|
|
54
55
|
const frame = await extractFrameAt(videoPath, tempDir, timestamp);
|
|
55
56
|
const optimizedPath = getTempFilePath(tempDir, `opt_frame_at.jpg`);
|
|
56
57
|
await optimizeFrame(frame.filePath, optimizedPath);
|
|
57
|
-
await
|
|
58
|
+
await progress(100, 'Frame extracted');
|
|
58
59
|
return {
|
|
59
60
|
content: [
|
|
60
61
|
{ type: 'text', text: `Frame extracted at ${timestamp}` },
|
|
@@ -64,13 +65,13 @@ Returns: A single image of the video frame at the specified timestamp.`,
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
// Strategy 2: Browser-based extraction (fallback)
|
|
67
|
-
await
|
|
68
|
+
await progress(30, 'Extracting frame via browser fallback...');
|
|
68
69
|
const seconds = parseTimestamp(timestamp);
|
|
69
70
|
const browserFrames = await extractBrowserFrames(url, tempDir, {
|
|
70
71
|
timestamps: [seconds],
|
|
71
72
|
});
|
|
72
73
|
if (browserFrames.length > 0) {
|
|
73
|
-
await
|
|
74
|
+
await progress(100, 'Frame extracted');
|
|
74
75
|
return {
|
|
75
76
|
content: [
|
|
76
77
|
{
|
|
@@ -4,6 +4,7 @@ import { getAdapter } from '../adapters/adapter.interface.js';
|
|
|
4
4
|
import { extractBrowserFrames } from '../processors/browser-frame-extractor.js';
|
|
5
5
|
import { extractFrameBurst, parseTimestamp } from '../processors/frame-extractor.js';
|
|
6
6
|
import { optimizeFrames } from '../processors/image-optimizer.js';
|
|
7
|
+
import { createProgressReporter } from '../utils/progress.js';
|
|
7
8
|
import { createTempDir } from '../utils/temp-files.js';
|
|
8
9
|
const GetFrameBurstSchema = z.object({
|
|
9
10
|
url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
|
|
@@ -53,20 +54,21 @@ Returns: N images evenly distributed between the from and to timestamps.`,
|
|
|
53
54
|
openWorldHint: true,
|
|
54
55
|
},
|
|
55
56
|
execute: async (args, { reportProgress }) => {
|
|
57
|
+
const progress = createProgressReporter(reportProgress);
|
|
56
58
|
const { url, from, to, count } = args;
|
|
57
59
|
const frameCount = count ?? 5;
|
|
58
60
|
const adapter = getAdapter(url);
|
|
59
|
-
await
|
|
61
|
+
await progress(0, `Starting burst extraction (${from} → ${to})...`);
|
|
60
62
|
const tempDir = await createTempDir();
|
|
61
63
|
// Strategy 1: Download video + ffmpeg burst extraction
|
|
62
64
|
if (adapter.capabilities.videoDownload) {
|
|
63
65
|
const videoPath = await adapter.downloadVideo(url, tempDir);
|
|
64
66
|
if (videoPath) {
|
|
65
|
-
await
|
|
67
|
+
await progress(40, 'Video downloaded, extracting burst frames...');
|
|
66
68
|
const frames = await extractFrameBurst(videoPath, tempDir, from, to, frameCount);
|
|
67
|
-
await
|
|
69
|
+
await progress(70, `Extracted ${frames.length} frames, optimizing...`);
|
|
68
70
|
const optimizedPaths = await optimizeFrames(frames.map((f) => f.filePath), tempDir);
|
|
69
|
-
await
|
|
71
|
+
await progress(100, 'Burst extraction complete');
|
|
70
72
|
const content = [
|
|
71
73
|
{
|
|
72
74
|
type: 'text',
|
|
@@ -80,14 +82,14 @@ Returns: N images evenly distributed between the from and to timestamps.`,
|
|
|
80
82
|
}
|
|
81
83
|
}
|
|
82
84
|
// Strategy 2: Browser-based extraction (fallback)
|
|
83
|
-
await
|
|
85
|
+
await progress(30, 'Extracting frames via browser fallback...');
|
|
84
86
|
const fromSeconds = parseTimestamp(from);
|
|
85
87
|
const toSeconds = parseTimestamp(to);
|
|
86
88
|
const interval = (toSeconds - fromSeconds) / Math.max(frameCount - 1, 1);
|
|
87
89
|
const timestamps = Array.from({ length: frameCount }, (_, i) => Math.round(fromSeconds + i * interval));
|
|
88
90
|
const browserFrames = await extractBrowserFrames(url, tempDir, { timestamps });
|
|
89
91
|
if (browserFrames.length > 0) {
|
|
90
|
-
await
|
|
92
|
+
await progress(100, 'Burst extraction complete');
|
|
91
93
|
const content = [
|
|
92
94
|
{
|
|
93
95
|
type: 'text',
|
package/dist/tools/get-frames.js
CHANGED
|
@@ -5,6 +5,7 @@ import { extractBrowserFrames, generateTimestamps } from '../processors/browser-
|
|
|
5
5
|
import { deduplicateFrames, filterBlackFrames } from '../processors/frame-dedup.js';
|
|
6
6
|
import { extractDenseFrames, extractSceneFrames, formatTimestamp, probeVideoDuration, } from '../processors/frame-extractor.js';
|
|
7
7
|
import { optimizeFrames } from '../processors/image-optimizer.js';
|
|
8
|
+
import { createProgressReporter } from '../utils/progress.js';
|
|
8
9
|
import { createTempDir } from '../utils/temp-files.js';
|
|
9
10
|
const GetFramesSchema = z.object({
|
|
10
11
|
url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
|
|
@@ -53,6 +54,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
53
54
|
openWorldHint: true,
|
|
54
55
|
},
|
|
55
56
|
execute: async (args, { reportProgress }) => {
|
|
57
|
+
const progress = createProgressReporter(reportProgress);
|
|
56
58
|
const { url, options } = args;
|
|
57
59
|
const maxFrames = options?.maxFrames ?? 20;
|
|
58
60
|
const threshold = options?.threshold ?? 0.1;
|
|
@@ -68,7 +70,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
68
70
|
}
|
|
69
71
|
const warnings = [];
|
|
70
72
|
const tempDir = await createTempDir();
|
|
71
|
-
await
|
|
73
|
+
await progress(0, 'Starting frame extraction...');
|
|
72
74
|
// Get metadata for duration (needed for browser fallback)
|
|
73
75
|
const metadata = await adapter.getMetadata(url).catch(() => ({
|
|
74
76
|
platform: adapter.name,
|
|
@@ -82,7 +84,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
82
84
|
if (adapter.capabilities.videoDownload) {
|
|
83
85
|
const videoPath = await adapter.downloadVideo(url, tempDir);
|
|
84
86
|
if (videoPath) {
|
|
85
|
-
await
|
|
87
|
+
await progress(40, 'Video downloaded, extracting frames...');
|
|
86
88
|
if (metadata.duration === 0) {
|
|
87
89
|
const duration = await probeVideoDuration(videoPath).catch(() => 0);
|
|
88
90
|
metadata.duration = duration;
|
|
@@ -108,7 +110,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
108
110
|
}
|
|
109
111
|
// Strategy 2: Browser fallback
|
|
110
112
|
if (frames.length === 0 && metadata.duration > 0) {
|
|
111
|
-
await
|
|
113
|
+
await progress(40, 'Extracting frames via browser fallback...');
|
|
112
114
|
const timestamps = generateTimestamps(metadata.duration, maxFrames);
|
|
113
115
|
frames = await extractBrowserFrames(url, tempDir, { timestamps }).catch((e) => {
|
|
114
116
|
warnings.push(`Browser extraction failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -116,6 +118,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
116
118
|
});
|
|
117
119
|
}
|
|
118
120
|
// Filter black/blank frames
|
|
121
|
+
await progress(80, 'Filtering and deduplicating frames...');
|
|
119
122
|
if (frames.length > 0) {
|
|
120
123
|
const blackResult = await filterBlackFrames(frames).catch(() => ({
|
|
121
124
|
frames,
|
|
@@ -134,7 +137,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
134
137
|
warnings.push(`Removed ${before - frames.length} duplicate frames`);
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
|
-
await
|
|
140
|
+
await progress(100, 'Frames extracted');
|
|
138
141
|
if (frames.length === 0) {
|
|
139
142
|
throw new UserError('Could not extract any frames. Install yt-dlp or Chrome/Chromium for frame extraction.');
|
|
140
143
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { UserError } from 'fastmcp';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getAdapter } from '../adapters/adapter.interface.js';
|
|
4
|
+
import { createProgressReporter } from '../utils/progress.js';
|
|
4
5
|
const GetMetadataSchema = z.object({
|
|
5
6
|
url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
|
|
6
7
|
});
|
|
@@ -21,7 +22,8 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
21
22
|
idempotentHint: true,
|
|
22
23
|
openWorldHint: true,
|
|
23
24
|
},
|
|
24
|
-
execute: async (args) => {
|
|
25
|
+
execute: async (args, { reportProgress }) => {
|
|
26
|
+
const progress = createProgressReporter(reportProgress);
|
|
25
27
|
const { url } = args;
|
|
26
28
|
let adapter;
|
|
27
29
|
try {
|
|
@@ -33,6 +35,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
33
35
|
throw new UserError(`Failed to detect video platform for URL: ${url}`);
|
|
34
36
|
}
|
|
35
37
|
const warnings = [];
|
|
38
|
+
await progress(0, 'Fetching video metadata...');
|
|
36
39
|
const [metadata, comments, chapters, aiSummary] = await Promise.all([
|
|
37
40
|
adapter.getMetadata(url).catch((e) => {
|
|
38
41
|
warnings.push(`Failed to fetch metadata: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -51,6 +54,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
51
54
|
adapter.getChapters(url).catch(() => []),
|
|
52
55
|
adapter.getAiSummary(url).catch(() => null),
|
|
53
56
|
]);
|
|
57
|
+
await progress(100, 'Metadata fetched');
|
|
54
58
|
return {
|
|
55
59
|
content: [
|
|
56
60
|
{
|
|
@@ -2,6 +2,7 @@ import { UserError } from 'fastmcp';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { getAdapter } from '../adapters/adapter.interface.js';
|
|
4
4
|
import { extractAudioTrack, transcribeAudio } from '../processors/audio-transcriber.js';
|
|
5
|
+
import { createProgressReporter } from '../utils/progress.js';
|
|
5
6
|
import { cleanupTempDir, createTempDir } from '../utils/temp-files.js';
|
|
6
7
|
const GetTranscriptSchema = z.object({
|
|
7
8
|
url: z.string().url().describe('Video URL (Loom share link or direct mp4/webm URL)'),
|
|
@@ -26,7 +27,8 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
26
27
|
idempotentHint: true,
|
|
27
28
|
openWorldHint: true,
|
|
28
29
|
},
|
|
29
|
-
execute: async (args) => {
|
|
30
|
+
execute: async (args, { reportProgress }) => {
|
|
31
|
+
const progress = createProgressReporter(reportProgress);
|
|
30
32
|
const { url } = args;
|
|
31
33
|
let adapter;
|
|
32
34
|
try {
|
|
@@ -38,18 +40,22 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
38
40
|
throw new UserError(`Failed to detect video platform for URL: ${url}`);
|
|
39
41
|
}
|
|
40
42
|
const warnings = [];
|
|
43
|
+
await progress(0, 'Fetching transcript...');
|
|
41
44
|
// Try native transcript first
|
|
42
45
|
let transcript = await adapter.getTranscript(url).catch((e) => {
|
|
43
46
|
warnings.push(`Failed to fetch native transcript: ${e instanceof Error ? e.message : String(e)}`);
|
|
44
47
|
return [];
|
|
45
48
|
});
|
|
49
|
+
await progress(40, 'Native transcript fetched');
|
|
46
50
|
// Whisper fallback if no native transcript
|
|
47
51
|
if (transcript.length === 0 && adapter.capabilities.videoDownload) {
|
|
48
52
|
let tempDir = null;
|
|
49
53
|
try {
|
|
54
|
+
await progress(45, 'No native transcript, downloading video for Whisper...');
|
|
50
55
|
tempDir = await createTempDir();
|
|
51
56
|
const videoPath = await adapter.downloadVideo(url, tempDir);
|
|
52
57
|
if (videoPath) {
|
|
58
|
+
await progress(65, 'Transcribing audio with Whisper...');
|
|
53
59
|
const audioPath = await extractAudioTrack(videoPath, tempDir);
|
|
54
60
|
transcript = await transcribeAudio(audioPath);
|
|
55
61
|
if (transcript.length > 0) {
|
|
@@ -68,6 +74,7 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
68
74
|
if (transcript.length === 0) {
|
|
69
75
|
warnings.push('No transcript available for this video.');
|
|
70
76
|
}
|
|
77
|
+
await progress(100, 'Transcript complete');
|
|
71
78
|
return {
|
|
72
79
|
content: [
|
|
73
80
|
{
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress reporting utility with descriptive messages.
|
|
3
|
+
*
|
|
4
|
+
* The MCP spec supports an optional `message` field in progress notifications.
|
|
5
|
+
* FastMCP's TypeScript type omits it, but the runtime passes it through via spread.
|
|
6
|
+
*/
|
|
7
|
+
type ReportProgressFn = (progress: {
|
|
8
|
+
progress: number;
|
|
9
|
+
total?: number;
|
|
10
|
+
}) => Promise<void>;
|
|
11
|
+
export declare function createProgressReporter(reportProgress: ReportProgressFn, total?: number): (progress: number, message?: string) => Promise<void>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress reporting utility with descriptive messages.
|
|
3
|
+
*
|
|
4
|
+
* The MCP spec supports an optional `message` field in progress notifications.
|
|
5
|
+
* FastMCP's TypeScript type omits it, but the runtime passes it through via spread.
|
|
6
|
+
*/
|
|
7
|
+
export function createProgressReporter(reportProgress, total = 100) {
|
|
8
|
+
return async (progress, message) => {
|
|
9
|
+
const payload = { progress, total };
|
|
10
|
+
if (message) {
|
|
11
|
+
payload.message = message;
|
|
12
|
+
}
|
|
13
|
+
await reportProgress(payload);
|
|
14
|
+
};
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-video-analyzer",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "MCP server for video analysis — extracts transcripts, key frames, OCR text, and metadata from video URLs. Supports Loom and direct video files.",
|
|
5
5
|
"author": "guimatheus92",
|
|
6
6
|
"license": "MIT",
|