mcp-video-analyzer 0.2.4 → 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/dist/processors/frame-ocr.d.ts +1 -1
- package/dist/processors/frame-ocr.js +4 -2
- package/dist/server.js +1 -1
- package/dist/tools/analyze-moment.js +14 -8
- package/dist/tools/analyze-video.js +17 -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
|
@@ -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[], language?: string): 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, language = 'eng+por') {
|
|
7
|
+
export async function extractTextFromFrames(frames, language = 'eng+por', onProgress) {
|
|
8
8
|
const Tesseract = await loadTesseract();
|
|
9
9
|
if (!Tesseract)
|
|
10
10
|
return [];
|
|
11
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, language = 'eng+por') {
|
|
|
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)'),
|
|
@@ -49,6 +50,7 @@ Requires video download capability for frame extraction.`,
|
|
|
49
50
|
openWorldHint: true,
|
|
50
51
|
},
|
|
51
52
|
execute: async (args, { reportProgress }) => {
|
|
53
|
+
const progress = createProgressReporter(reportProgress);
|
|
52
54
|
const { url, from, to } = args;
|
|
53
55
|
const count = args.count ?? 10;
|
|
54
56
|
const ocrLanguage = args.ocrLanguage ?? 'eng+por';
|
|
@@ -69,7 +71,7 @@ Requires video download capability for frame extraction.`,
|
|
|
69
71
|
}
|
|
70
72
|
const warnings = [];
|
|
71
73
|
const tempDir = await createTempDir();
|
|
72
|
-
await
|
|
74
|
+
await progress(0, `Starting moment analysis (${from} → ${to})...`);
|
|
73
75
|
// Fetch transcript and filter to time range
|
|
74
76
|
const fullTranscript = await adapter.getTranscript(url).catch((e) => {
|
|
75
77
|
warnings.push(`Failed to fetch transcript: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -79,7 +81,7 @@ Requires video download capability for frame extraction.`,
|
|
|
79
81
|
const entrySeconds = parseTimestampLoose(entry.time);
|
|
80
82
|
return entrySeconds !== null && entrySeconds >= fromSeconds && entrySeconds <= toSeconds;
|
|
81
83
|
});
|
|
82
|
-
await
|
|
84
|
+
await progress(15, 'Transcript filtered to time range');
|
|
83
85
|
// Download video and extract burst frames
|
|
84
86
|
if (!adapter.capabilities.videoDownload) {
|
|
85
87
|
throw new UserError('Moment analysis requires video download capability. Use a direct video URL (.mp4, .webm, .mov).');
|
|
@@ -88,9 +90,9 @@ Requires video download capability for frame extraction.`,
|
|
|
88
90
|
if (!videoPath) {
|
|
89
91
|
throw new UserError('Failed to download video for moment analysis.');
|
|
90
92
|
}
|
|
91
|
-
await
|
|
93
|
+
await progress(35, 'Video downloaded, extracting burst frames...');
|
|
92
94
|
const rawFrames = await extractFrameBurst(videoPath, tempDir, from, to, count);
|
|
93
|
-
await
|
|
95
|
+
await progress(55, `Extracted ${rawFrames.length} frames, optimizing...`);
|
|
94
96
|
// Optimize frames
|
|
95
97
|
const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
|
|
96
98
|
warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -106,16 +108,20 @@ Requires video download capability for frame extraction.`,
|
|
|
106
108
|
if (frames.length < beforeDedup) {
|
|
107
109
|
warnings.push(`Removed ${beforeDedup - frames.length} near-duplicate frames (${beforeDedup} → ${frames.length})`);
|
|
108
110
|
}
|
|
109
|
-
await
|
|
111
|
+
await progress(70, 'Filtering and deduplicating frames...');
|
|
110
112
|
// OCR
|
|
111
|
-
|
|
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) => {
|
|
112
118
|
warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
113
119
|
return [];
|
|
114
120
|
});
|
|
115
|
-
await
|
|
121
|
+
await progress(92, 'Building annotated timeline...');
|
|
116
122
|
// Build mini-timeline for this range
|
|
117
123
|
const timeline = buildAnnotatedTimeline(transcriptSegment, frames, ocrResults);
|
|
118
|
-
await
|
|
124
|
+
await progress(100, 'Moment analysis complete');
|
|
119
125
|
// Build response
|
|
120
126
|
const textData = {
|
|
121
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 = [
|
|
@@ -103,6 +104,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
103
104
|
openWorldHint: true,
|
|
104
105
|
},
|
|
105
106
|
execute: async (args, { reportProgress }) => {
|
|
107
|
+
const progress = createProgressReporter(reportProgress);
|
|
106
108
|
const { url, options } = args;
|
|
107
109
|
const detail = options?.detail ?? 'standard';
|
|
108
110
|
const forceRefresh = options?.forceRefresh ?? false;
|
|
@@ -147,7 +149,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
147
149
|
const warnings = [];
|
|
148
150
|
let tempDir = null;
|
|
149
151
|
try {
|
|
150
|
-
await
|
|
152
|
+
await progress(0, 'Starting video analysis...');
|
|
151
153
|
// Fetch metadata, transcript, comments in parallel
|
|
152
154
|
const [metadata, transcript, comments, chapters, aiSummary] = await Promise.all([
|
|
153
155
|
adapter.getMetadata(url).catch((e) => {
|
|
@@ -171,7 +173,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
171
173
|
adapter.getChapters(url).catch(() => []),
|
|
172
174
|
adapter.getAiSummary(url).catch(() => null),
|
|
173
175
|
]);
|
|
174
|
-
await
|
|
176
|
+
await progress(35, 'Metadata and transcript fetched');
|
|
175
177
|
// Apply transcript limit for brief mode
|
|
176
178
|
const limitedTranscript = config.transcriptMaxEntries !== null
|
|
177
179
|
? transcript.slice(0, config.transcriptMaxEntries)
|
|
@@ -196,7 +198,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
196
198
|
if (adapter.capabilities.videoDownload) {
|
|
197
199
|
videoPath = await adapter.downloadVideo(url, tempDir);
|
|
198
200
|
if (videoPath) {
|
|
199
|
-
await
|
|
201
|
+
await progress(50, 'Video downloaded, extracting frames...');
|
|
200
202
|
// Probe duration if metadata didn't provide it
|
|
201
203
|
if (metadata.duration === 0) {
|
|
202
204
|
const duration = await probeVideoDuration(videoPath).catch(() => 0);
|
|
@@ -216,7 +218,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
216
218
|
warnings.push(`Frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
217
219
|
return [];
|
|
218
220
|
});
|
|
219
|
-
await
|
|
221
|
+
await progress(70, `Extracted ${rawFrames.length} frames, optimizing...`);
|
|
220
222
|
if (rawFrames.length > 0) {
|
|
221
223
|
const optimizedPaths = await optimizeFrames(rawFrames.map((f) => f.filePath), tempDir).catch((e) => {
|
|
222
224
|
warnings.push(`Frame optimization failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -232,7 +234,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
232
234
|
}
|
|
233
235
|
// Strategy 2: Browser-based extraction (fallback)
|
|
234
236
|
if (!framesExtracted && metadata.duration > 0) {
|
|
235
|
-
await
|
|
237
|
+
await progress(50, 'Extracting frames via browser fallback...');
|
|
236
238
|
const timestamps = generateTimestamps(metadata.duration, maxFrames);
|
|
237
239
|
const browserFrames = await extractBrowserFrames(url, tempDir, {
|
|
238
240
|
timestamps,
|
|
@@ -240,7 +242,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
240
242
|
warnings.push(`Browser frame extraction failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
241
243
|
return [];
|
|
242
244
|
});
|
|
243
|
-
await
|
|
245
|
+
await progress(70, `Browser extracted ${browserFrames.length} frames`);
|
|
244
246
|
if (browserFrames.length > 0) {
|
|
245
247
|
result.frames = browserFrames;
|
|
246
248
|
framesExtracted = true;
|
|
@@ -269,18 +271,23 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
269
271
|
if (result.frames.length < beforeDedup) {
|
|
270
272
|
warnings.push(`Removed ${beforeDedup - result.frames.length} near-duplicate frames (${beforeDedup} → ${result.frames.length})`);
|
|
271
273
|
}
|
|
272
|
-
await
|
|
274
|
+
await progress(80, 'Filtering and deduplicating frames...');
|
|
273
275
|
// OCR: extract text visible on screen
|
|
274
276
|
if (config.includeOcr) {
|
|
275
|
-
|
|
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) => {
|
|
276
282
|
warnings.push(`OCR failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
277
283
|
return [];
|
|
278
284
|
});
|
|
279
285
|
}
|
|
280
|
-
await
|
|
286
|
+
await progress(93, 'OCR complete');
|
|
281
287
|
}
|
|
282
288
|
// Build annotated timeline
|
|
283
289
|
if (config.includeTimeline) {
|
|
290
|
+
await progress(95, 'Building annotated timeline...');
|
|
284
291
|
result.timeline = buildAnnotatedTimeline(result.transcript, result.frames, result.ocrResults);
|
|
285
292
|
}
|
|
286
293
|
}
|
|
@@ -305,7 +312,7 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
305
312
|
// Audio extraction or transcription failed — not critical
|
|
306
313
|
}
|
|
307
314
|
}
|
|
308
|
-
await
|
|
315
|
+
await progress(100, 'Analysis complete');
|
|
309
316
|
// Cache the full result
|
|
310
317
|
cache.set(key, result);
|
|
311
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",
|