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.
@@ -14,4 +14,3 @@ export function getAdapter(url) {
14
14
  export function clearAdapters() {
15
15
  adapters.length = 0;
16
16
  }
17
- //# sourceMappingURL=adapter.interface.js.map
@@ -64,4 +64,3 @@ export class DirectAdapter {
64
64
  return destPath;
65
65
  }
66
66
  }
67
- //# sourceMappingURL=direct.adapter.js.map
@@ -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 execFile(ytDlp.bin, [...ytDlp.prefix, '-o', outputPath, '--no-warnings', '-q', url], {
133
- timeout: 120000,
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
- return existsSync(outputPath) ? outputPath : null;
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
- return null;
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
@@ -27,4 +27,3 @@ export const DETAIL_CONFIGS = {
27
27
  export function getDetailConfig(level) {
28
28
  return DETAIL_CONFIGS[level];
29
29
  }
30
- //# sourceMappingURL=detail-levels.js.map
package/dist/index.js CHANGED
@@ -2,4 +2,3 @@
2
2
  import { createServer } from './server.js';
3
3
  const server = createServer();
4
4
  server.start({ transportType: 'stdio' });
5
- //# sourceMappingURL=index.js.map
@@ -80,4 +80,3 @@ export function parseTimeToSeconds(time) {
80
80
  }
81
81
  return 0;
82
82
  }
83
- //# sourceMappingURL=annotated-timeline.js.map
@@ -188,4 +188,3 @@ async function loadTransformers() {
188
188
  return null;
189
189
  }
190
190
  }
191
- //# sourceMappingURL=audio-transcriber.js.map
@@ -129,4 +129,3 @@ async function loadPuppeteer() {
129
129
  return null;
130
130
  }
131
131
  }
132
- //# sourceMappingURL=browser-frame-extractor.js.map
@@ -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
@@ -198,4 +198,3 @@ async function listFrameFiles(dir, prefix) {
198
198
  const files = await readdir(dir);
199
199
  return files.filter((f) => f.startsWith(prefix) && f.endsWith('.jpg')).sort();
200
200
  }
201
- //# sourceMappingURL=frame-extractor.js.map
@@ -42,4 +42,3 @@ async function loadTesseract() {
42
42
  return null;
43
43
  }
44
44
  }
45
- //# sourceMappingURL=frame-ocr.js.map
@@ -18,4 +18,3 @@ export async function optimizeFrames(inputPaths, outputDir, options = {}) {
18
18
  }
19
19
  return results;
20
20
  }
21
- //# sourceMappingURL=image-optimizer.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.0',
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
@@ -142,4 +142,3 @@ function parseTimestampLoose(ts) {
142
142
  return isNaN(n) ? null : n;
143
143
  }
144
144
  }
145
- //# sourceMappingURL=analyze-moment.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
@@ -85,4 +85,3 @@ Returns: A single image of the video frame at the specified timestamp.`,
85
85
  },
86
86
  });
87
87
  }
88
- //# sourceMappingURL=get-frame-at.js.map
@@ -103,4 +103,3 @@ Returns: N images evenly distributed between the from and to timestamps.`,
103
103
  },
104
104
  });
105
105
  }
106
- //# sourceMappingURL=get-frame-burst.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 { 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
@@ -62,4 +62,3 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
62
62
  },
63
63
  });
64
64
  }
65
- //# sourceMappingURL=get-metadata.js.map
@@ -79,4 +79,3 @@ Supports: Loom (loom.com/share/...) and direct video URLs (.mp4, .webm, .mov).`,
79
79
  },
80
80
  });
81
81
  }
82
- //# sourceMappingURL=get-transcript.js.map
package/dist/types.js CHANGED
@@ -1,2 +1 @@
1
1
  export {};
2
- //# sourceMappingURL=types.js.map
@@ -84,4 +84,3 @@ function sortKeys(obj) {
84
84
  }
85
85
  return sorted;
86
86
  }
87
- //# sourceMappingURL=cache.js.map
@@ -29,4 +29,3 @@ export function filterAnalysisResult(result, fields) {
29
29
  }
30
30
  return filtered;
31
31
  }
32
- //# sourceMappingURL=field-filter.js.map
@@ -25,4 +25,3 @@ function cleanupAllTempDirs() {
25
25
  activeTempDirs.clear();
26
26
  }
27
27
  process.on('exit', cleanupAllTempDirs);
28
- //# sourceMappingURL=temp-files.js.map
@@ -30,4 +30,3 @@ function getExtension(pathname) {
30
30
  return null;
31
31
  return pathname.slice(lastDot).toLowerCase();
32
32
  }
33
- //# sourceMappingURL=url-detector.js.map
@@ -82,4 +82,3 @@ function formatTimestamp(vttTimestamp) {
82
82
  }
83
83
  return `${minutes}:${String(seconds).padStart(2, '0')}`;
84
84
  }
85
- //# sourceMappingURL=vtt-parser.js.map
package/package.json CHANGED
@@ -1,78 +1,81 @@
1
- {
2
- "name": "mcp-video-analyzer",
3
- "version": "0.2.1",
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
- "prepublishOnly": "npm run check && npm run build"
41
- },
42
- "keywords": [
43
- "mcp",
44
- "model-context-protocol",
45
- "video",
46
- "video-analysis",
47
- "loom",
48
- "transcript",
49
- "frames",
50
- "ocr",
51
- "screen-recording",
52
- "claude",
53
- "ai"
54
- ],
55
- "engines": {
56
- "node": ">=18"
57
- },
58
- "dependencies": {
59
- "cheerio": "^1.2.0",
60
- "fastmcp": "^3.34.0",
61
- "ffmpeg-static": "^5.3.0",
62
- "puppeteer-core": "^24.39.0",
63
- "sharp": "^0.34.5",
64
- "tesseract.js": "^7.0.0",
65
- "zod": "^4.3.6"
66
- },
67
- "devDependencies": {
68
- "@eslint/js": "^10.0.1",
69
- "@types/node": "^25.4.0",
70
- "@vitest/coverage-v8": "^4.0.18",
71
- "eslint": "^10.0.3",
72
- "knip": "^5.86.0",
73
- "prettier": "^3.8.1",
74
- "typescript": "^5.9.3",
75
- "typescript-eslint": "^8.57.0",
76
- "vitest": "^4.0.18"
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
+ }