mcp-video-analyzer 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/adapter.interface.js +0 -1
- package/dist/adapters/direct.adapter.js +0 -1
- package/dist/adapters/loom.adapter.d.ts +1 -0
- package/dist/adapters/loom.adapter.js +58 -10
- package/dist/config/detail-levels.js +0 -1
- package/dist/index.js +0 -1
- package/dist/processors/annotated-timeline.js +0 -1
- package/dist/processors/audio-transcriber.js +0 -1
- package/dist/processors/browser-frame-extractor.js +0 -1
- package/dist/processors/frame-dedup.d.ts +13 -0
- package/dist/processors/frame-dedup.js +32 -1
- package/dist/processors/frame-extractor.js +0 -1
- package/dist/processors/frame-ocr.js +0 -1
- package/dist/processors/image-optimizer.js +0 -1
- package/dist/server.js +1 -2
- package/dist/tools/analyze-moment.js +0 -1
- package/dist/tools/analyze-video.js +12 -3
- package/dist/tools/get-frame-at.js +0 -1
- package/dist/tools/get-frame-burst.js +0 -1
- package/dist/tools/get-frames.js +12 -2
- package/dist/tools/get-metadata.js +0 -1
- package/dist/tools/get-transcript.js +0 -1
- package/dist/types.js +0 -1
- package/dist/utils/cache.js +0 -1
- package/dist/utils/field-filter.js +0 -1
- package/dist/utils/temp-files.js +0 -1
- package/dist/utils/url-detector.js +0 -1
- package/dist/utils/vtt-parser.js +0 -1
- package/package.json +81 -78
|
@@ -10,4 +10,5 @@ export declare class LoomAdapter implements IVideoAdapter {
|
|
|
10
10
|
getChapters(_url: string): Promise<IChapter[]>;
|
|
11
11
|
getAiSummary(_url: string): Promise<string | null>;
|
|
12
12
|
downloadVideo(url: string, destDir: string): Promise<string | null>;
|
|
13
|
+
private fetchVideoUrl;
|
|
13
14
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { execFile as execFileCb } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
4
|
+
import { existsSync, createWriteStream } from 'node:fs';
|
|
5
|
+
import { pipeline } from 'node:stream/promises';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
5
7
|
import { detectPlatform, extractLoomId } from '../utils/url-detector.js';
|
|
6
8
|
import { parseVtt } from '../utils/vtt-parser.js';
|
|
7
9
|
const execFile = promisify(execFileCb);
|
|
@@ -122,21 +124,68 @@ export class LoomAdapter {
|
|
|
122
124
|
return null;
|
|
123
125
|
}
|
|
124
126
|
async downloadVideo(url, destDir) {
|
|
125
|
-
// Try yt-dlp for Loom video download (works without auth for public videos)
|
|
126
|
-
const ytDlp = await findYtDlp();
|
|
127
|
-
if (!ytDlp)
|
|
128
|
-
return null;
|
|
129
127
|
const videoId = extractLoomId(url);
|
|
130
128
|
const outputPath = join(destDir, `${videoId ?? 'loom_video'}.mp4`);
|
|
129
|
+
// Strategy 1: yt-dlp (best quality, handles edge cases)
|
|
130
|
+
const ytDlp = await findYtDlp();
|
|
131
|
+
if (ytDlp) {
|
|
132
|
+
try {
|
|
133
|
+
await execFile(ytDlp.bin, [...ytDlp.prefix, '-o', outputPath, '--no-warnings', '-q', url], {
|
|
134
|
+
timeout: 120000,
|
|
135
|
+
});
|
|
136
|
+
if (existsSync(outputPath))
|
|
137
|
+
return outputPath;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Fall through to direct download
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Strategy 2: Direct HTTP download via Loom CDN URL
|
|
144
|
+
if (!videoId)
|
|
145
|
+
return null;
|
|
146
|
+
const videoUrl = await this.fetchVideoUrl(videoId);
|
|
147
|
+
if (videoUrl) {
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(videoUrl);
|
|
150
|
+
if (response.ok && response.body) {
|
|
151
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
152
|
+
await pipeline(nodeStream, createWriteStream(outputPath));
|
|
153
|
+
if (existsSync(outputPath))
|
|
154
|
+
return outputPath;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Download failed
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
async fetchVideoUrl(videoId) {
|
|
164
|
+
// Loom exposes video URLs through their fetch endpoint
|
|
131
165
|
try {
|
|
132
|
-
await
|
|
133
|
-
|
|
166
|
+
const response = await fetch(`https://www.loom.com/api/campaigns/sessions/${videoId}/transcoded-url`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: GRAPHQL_HEADERS,
|
|
169
|
+
body: JSON.stringify({}),
|
|
134
170
|
});
|
|
135
|
-
|
|
171
|
+
if (response.ok) {
|
|
172
|
+
const data = (await response.json());
|
|
173
|
+
if (data.url)
|
|
174
|
+
return data.url;
|
|
175
|
+
}
|
|
136
176
|
}
|
|
137
177
|
catch {
|
|
138
|
-
|
|
178
|
+
// Try alternative approach
|
|
179
|
+
}
|
|
180
|
+
// Fallback: query the GraphQL API for video source URL
|
|
181
|
+
const data = await loomGraphQL(`query GetVideoUrl($videoId: ID!, $password: String) {
|
|
182
|
+
getVideo(id: $videoId, password: $password) {
|
|
183
|
+
... on RegularUserVideo {
|
|
184
|
+
source_url
|
|
185
|
+
}
|
|
139
186
|
}
|
|
187
|
+
}`, { videoId, password: null });
|
|
188
|
+
return data?.getVideo?.source_url ?? null;
|
|
140
189
|
}
|
|
141
190
|
}
|
|
142
191
|
function flattenComments(comments) {
|
|
@@ -180,4 +229,3 @@ async function findYtDlp() {
|
|
|
180
229
|
return null;
|
|
181
230
|
}
|
|
182
231
|
}
|
|
183
|
-
//# sourceMappingURL=loom.adapter.js.map
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import type { IFrameResult } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Check if a frame is effectively black/blank.
|
|
4
|
+
* Computes the mean brightness of the image — if below threshold, it's black.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isBlackFrame(filePath: string, threshold?: number): Promise<boolean>;
|
|
7
|
+
/**
|
|
8
|
+
* Filter out black/blank frames from the array.
|
|
9
|
+
* Returns the filtered frames and count of removed frames.
|
|
10
|
+
*/
|
|
11
|
+
export declare function filterBlackFrames(frames: IFrameResult[], threshold?: number): Promise<{
|
|
12
|
+
frames: IFrameResult[];
|
|
13
|
+
removedCount: number;
|
|
14
|
+
}>;
|
|
2
15
|
/**
|
|
3
16
|
* Compute a difference hash (dHash) for an image.
|
|
4
17
|
* Resize to 9x8 grayscale, then compare each pixel to its right neighbor.
|
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
import sharp from 'sharp';
|
|
2
2
|
const HASH_WIDTH = 9;
|
|
3
3
|
const HASH_HEIGHT = 8;
|
|
4
|
+
/**
|
|
5
|
+
* Check if a frame is effectively black/blank.
|
|
6
|
+
* Computes the mean brightness of the image — if below threshold, it's black.
|
|
7
|
+
*/
|
|
8
|
+
export async function isBlackFrame(filePath, threshold = 10) {
|
|
9
|
+
try {
|
|
10
|
+
const { channels } = await sharp(filePath).stats();
|
|
11
|
+
// Average the mean of all channels (R, G, B)
|
|
12
|
+
const meanBrightness = channels.reduce((sum, ch) => sum + ch.mean, 0) / channels.length;
|
|
13
|
+
return meanBrightness < threshold;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false; // If we can't analyze, keep the frame
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Filter out black/blank frames from the array.
|
|
21
|
+
* Returns the filtered frames and count of removed frames.
|
|
22
|
+
*/
|
|
23
|
+
export async function filterBlackFrames(frames, threshold = 10) {
|
|
24
|
+
if (frames.length === 0)
|
|
25
|
+
return { frames, removedCount: 0 };
|
|
26
|
+
const results = await Promise.all(frames.map(async (frame) => ({
|
|
27
|
+
frame,
|
|
28
|
+
isBlack: await isBlackFrame(frame.filePath, threshold),
|
|
29
|
+
})));
|
|
30
|
+
const filtered = results.filter((r) => !r.isBlack).map((r) => r.frame);
|
|
31
|
+
return {
|
|
32
|
+
frames: filtered,
|
|
33
|
+
removedCount: frames.length - filtered.length,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
4
36
|
/**
|
|
5
37
|
* Compute a difference hash (dHash) for an image.
|
|
6
38
|
* Resize to 9x8 grayscale, then compare each pixel to its right neighbor.
|
|
@@ -73,4 +105,3 @@ export async function deduplicateFrames(frames, maxDistance = 5) {
|
|
|
73
105
|
}
|
|
74
106
|
return result;
|
|
75
107
|
}
|
|
76
|
-
//# sourceMappingURL=frame-dedup.js.map
|
package/dist/server.js
CHANGED
|
@@ -12,7 +12,7 @@ import { registerAnalyzeMoment } from './tools/analyze-moment.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.2',
|
|
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:
|
|
@@ -64,4 +64,3 @@ Decision flow:
|
|
|
64
64
|
registerAnalyzeMoment(server);
|
|
65
65
|
return server;
|
|
66
66
|
}
|
|
67
|
-
//# sourceMappingURL=server.js.map
|
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getAdapter } from '../adapters/adapter.interface.js';
|
|
4
4
|
import { extractSceneFrames, extractDenseFrames, probeVideoDuration, formatTimestamp, } from '../processors/frame-extractor.js';
|
|
5
5
|
import { extractBrowserFrames, generateTimestamps } from '../processors/browser-frame-extractor.js';
|
|
6
|
-
import { deduplicateFrames } from '../processors/frame-dedup.js';
|
|
6
|
+
import { deduplicateFrames, filterBlackFrames } from '../processors/frame-dedup.js';
|
|
7
7
|
import { extractTextFromFrames } from '../processors/frame-ocr.js';
|
|
8
8
|
import { buildAnnotatedTimeline } from '../processors/annotated-timeline.js';
|
|
9
9
|
import { optimizeFrames } from '../processors/image-optimizer.js';
|
|
@@ -244,7 +244,17 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
244
244
|
if (!framesExtracted) {
|
|
245
245
|
warnings.push('Frame extraction not available — returning transcript and metadata only. Install yt-dlp or Chrome/Chromium for frame extraction.');
|
|
246
246
|
}
|
|
247
|
-
// Post-processing: dedup, OCR, timeline
|
|
247
|
+
// Post-processing: filter black frames, dedup, OCR, timeline
|
|
248
|
+
if (result.frames.length > 0) {
|
|
249
|
+
const blackResult = await filterBlackFrames(result.frames).catch(() => ({
|
|
250
|
+
frames: result.frames,
|
|
251
|
+
removedCount: 0,
|
|
252
|
+
}));
|
|
253
|
+
if (blackResult.removedCount > 0) {
|
|
254
|
+
warnings.push(`Removed ${blackResult.removedCount} black/blank frame(s) — video may be DRM-protected`);
|
|
255
|
+
}
|
|
256
|
+
result.frames = blackResult.frames;
|
|
257
|
+
}
|
|
248
258
|
if (result.frames.length > 0) {
|
|
249
259
|
const beforeDedup = result.frames.length;
|
|
250
260
|
result.frames = await deduplicateFrames(result.frames).catch((e) => {
|
|
@@ -317,4 +327,3 @@ Use options.forceRefresh to bypass the cache.`,
|
|
|
317
327
|
},
|
|
318
328
|
});
|
|
319
329
|
}
|
|
320
|
-
//# sourceMappingURL=analyze-video.js.map
|
package/dist/tools/get-frames.js
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { getAdapter } from '../adapters/adapter.interface.js';
|
|
4
4
|
import { extractSceneFrames, extractDenseFrames, probeVideoDuration, formatTimestamp, } from '../processors/frame-extractor.js';
|
|
5
5
|
import { extractBrowserFrames, generateTimestamps } from '../processors/browser-frame-extractor.js';
|
|
6
|
-
import { deduplicateFrames } from '../processors/frame-dedup.js';
|
|
6
|
+
import { deduplicateFrames, filterBlackFrames } from '../processors/frame-dedup.js';
|
|
7
7
|
import { optimizeFrames } from '../processors/image-optimizer.js';
|
|
8
8
|
import { createTempDir } from '../utils/temp-files.js';
|
|
9
9
|
const GetFramesSchema = z.object({
|
|
@@ -115,6 +115,17 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
115
115
|
return [];
|
|
116
116
|
});
|
|
117
117
|
}
|
|
118
|
+
// Filter black/blank frames
|
|
119
|
+
if (frames.length > 0) {
|
|
120
|
+
const blackResult = await filterBlackFrames(frames).catch(() => ({
|
|
121
|
+
frames,
|
|
122
|
+
removedCount: 0,
|
|
123
|
+
}));
|
|
124
|
+
if (blackResult.removedCount > 0) {
|
|
125
|
+
warnings.push(`Removed ${blackResult.removedCount} black/blank frame(s) — video may be DRM-protected`);
|
|
126
|
+
}
|
|
127
|
+
frames = blackResult.frames;
|
|
128
|
+
}
|
|
118
129
|
// Dedup
|
|
119
130
|
if (frames.length > 0) {
|
|
120
131
|
const before = frames.length;
|
|
@@ -140,4 +151,3 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
|
|
|
140
151
|
},
|
|
141
152
|
});
|
|
142
153
|
}
|
|
143
|
-
//# sourceMappingURL=get-frames.js.map
|
package/dist/types.js
CHANGED
package/dist/utils/cache.js
CHANGED
package/dist/utils/temp-files.js
CHANGED
package/dist/utils/vtt-parser.js
CHANGED
package/package.json
CHANGED
|
@@ -1,78 +1,81 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "mcp-video-analyzer",
|
|
3
|
-
"version": "0.2.
|
|
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
|
-
"author": "guimatheus92",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"homepage": "https://github.com/guimatheus92/mcp-video-analyzer#readme",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "https://github.com/guimatheus92/mcp-video-analyzer.git"
|
|
11
|
-
},
|
|
12
|
-
"bugs": {
|
|
13
|
-
"url": "https://github.com/guimatheus92/mcp-video-analyzer/issues"
|
|
14
|
-
},
|
|
15
|
-
"bin": {
|
|
16
|
-
"mcp-video-analyzer": "./dist/index.js"
|
|
17
|
-
},
|
|
18
|
-
"main": "./dist/index.js",
|
|
19
|
-
"types": "./dist/index.d.ts",
|
|
20
|
-
"files": [
|
|
21
|
-
"dist"
|
|
22
|
-
],
|
|
23
|
-
"type": "module",
|
|
24
|
-
"scripts": {
|
|
25
|
-
"build": "tsc",
|
|
26
|
-
"prepare": "npm run build",
|
|
27
|
-
"dev": "npx fastmcp dev src/index.ts",
|
|
28
|
-
"inspect": "npx fastmcp inspect src/index.ts",
|
|
29
|
-
"lint": "eslint src/",
|
|
30
|
-
"lint:fix": "eslint src/ --fix",
|
|
31
|
-
"format": "prettier --write \"src/**/*.ts\" \"*.config.*\" \"*.json\"",
|
|
32
|
-
"format:check": "prettier --check \"src/**/*.ts\" \"*.config.*\"",
|
|
33
|
-
"typecheck": "tsc --noEmit",
|
|
34
|
-
"knip": "knip",
|
|
35
|
-
"test": "vitest run",
|
|
36
|
-
"test:watch": "vitest",
|
|
37
|
-
"test:coverage": "vitest run --coverage",
|
|
38
|
-
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
39
|
-
"check": "npm run format:check && npm run lint && npm run typecheck && npm run knip && npm run test",
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"@
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
77
|
-
|
|
78
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "mcp-video-analyzer",
|
|
3
|
+
"version": "0.2.2",
|
|
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
|
+
"author": "guimatheus92",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://github.com/guimatheus92/mcp-video-analyzer#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/guimatheus92/mcp-video-analyzer.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/guimatheus92/mcp-video-analyzer/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"mcp-video-analyzer": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"prepare": "npm run build",
|
|
27
|
+
"dev": "npx fastmcp dev src/index.ts",
|
|
28
|
+
"inspect": "npx fastmcp inspect src/index.ts",
|
|
29
|
+
"lint": "eslint src/",
|
|
30
|
+
"lint:fix": "eslint src/ --fix",
|
|
31
|
+
"format": "prettier --write \"src/**/*.ts\" \"*.config.*\" \"*.json\"",
|
|
32
|
+
"format:check": "prettier --check \"src/**/*.ts\" \"*.config.*\"",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"knip": "knip",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"test:coverage": "vitest run --coverage",
|
|
38
|
+
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
39
|
+
"check": "npm run format:check && npm run lint && npm run typecheck && npm run knip && npm run test",
|
|
40
|
+
"test:smoke": "npm run build && npx tsx scripts/smoke-test.ts",
|
|
41
|
+
"verify-package": "npm run build && npx tsx scripts/verify-package.ts",
|
|
42
|
+
"prepublishOnly": "npm run check && npm run build"
|
|
43
|
+
},
|
|
44
|
+
"keywords": [
|
|
45
|
+
"mcp",
|
|
46
|
+
"model-context-protocol",
|
|
47
|
+
"video",
|
|
48
|
+
"video-analysis",
|
|
49
|
+
"loom",
|
|
50
|
+
"transcript",
|
|
51
|
+
"frames",
|
|
52
|
+
"ocr",
|
|
53
|
+
"screen-recording",
|
|
54
|
+
"claude",
|
|
55
|
+
"ai"
|
|
56
|
+
],
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"cheerio": "^1.2.0",
|
|
62
|
+
"fastmcp": "^3.34.0",
|
|
63
|
+
"ffmpeg-static": "^5.3.0",
|
|
64
|
+
"puppeteer-core": "^24.39.0",
|
|
65
|
+
"sharp": "^0.34.5",
|
|
66
|
+
"tesseract.js": "^7.0.0",
|
|
67
|
+
"zod": "^4.3.6"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@eslint/js": "^10.0.1",
|
|
71
|
+
"@types/node": "^25.4.0",
|
|
72
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
73
|
+
"eslint": "^10.0.3",
|
|
74
|
+
"knip": "^5.86.0",
|
|
75
|
+
"prettier": "^3.8.1",
|
|
76
|
+
"tsx": "^4.21.0",
|
|
77
|
+
"typescript": "^5.9.3",
|
|
78
|
+
"typescript-eslint": "^8.57.0",
|
|
79
|
+
"vitest": "^4.0.18"
|
|
80
|
+
}
|
|
81
|
+
}
|