lemolabs-spotter-mcp 0.1.0

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 ADDED
@@ -0,0 +1,104 @@
1
+ # SPOTTER MCP Server
2
+
3
+ AI sound design spotting as an MCP tool for Claude Code.
4
+
5
+ Analyzes any video → generates a complete cue sheet → optionally generates or searches SFX audio → exports DAW-synchronized WAV stems.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ git clone <repo>
11
+ cd spotter/mcp-server
12
+ npm install
13
+ npm run build
14
+ ```
15
+
16
+ ## Add to Claude Code
17
+
18
+ Edit `~/.claude/settings.json`:
19
+
20
+ ```json
21
+ {
22
+ "mcpServers": {
23
+ "spotter": {
24
+ "command": "node",
25
+ "args": ["/absolute/path/to/spotter/mcp-server/dist/index.js"]
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Restart Claude Code. Verify with `/mcp` — you should see `spotter` listed.
32
+
33
+ ## Configure providers
34
+
35
+ ### ElevenLabs (SFX generation)
36
+
37
+ ```
38
+ configure_provider({ provider: "elevenlabs", api_key: "YOUR_KEY" })
39
+ ```
40
+
41
+ ### Freesound (SFX search)
42
+
43
+ Get a free API key at freesound.org/apiv2:
44
+
45
+ ```
46
+ configure_provider({ provider: "freesound", api_key: "YOUR_KEY" })
47
+ ```
48
+
49
+ ### Custom REST provider
50
+
51
+ Any API that accepts `POST { prompt, duration_seconds }` and returns audio:
52
+
53
+ ```
54
+ configure_provider({
55
+ provider: "my_sfx_api",
56
+ api_key: "YOUR_KEY",
57
+ endpoint: "https://api.example.com/v1/generate"
58
+ })
59
+ ```
60
+
61
+ Config stored in `~/.spotter/config.json`.
62
+
63
+ ## Tools
64
+
65
+ | Tool | Description |
66
+ |------|-------------|
67
+ | `analyze_video` | Extract frames → AI analysis → cue sheet (analysis.json) |
68
+ | `generate_sfx` | Generate audio from text prompt via ElevenLabs or custom provider |
69
+ | `search_sfx` | Search Freesound for existing recordings |
70
+ | `export_stems` | Export DAW-ready WAV stems + CSV cue sheet |
71
+ | `list_providers` | Show configured providers and their status |
72
+ | `configure_provider` | Add/update API keys and endpoints |
73
+
74
+ ## Typical workflow
75
+
76
+ ```
77
+ # 1. Analyze a video
78
+ analyze_video({ video_path: "/path/to/scene.mp4", criterio: "dark thriller, minimal music, emphasis on environment" })
79
+
80
+ # 2. Generate SFX for specific cues (returns audio files in project/media/)
81
+ generate_sfx({ prompt: "heavy rain on metal roof, distant thunder", duration_seconds: 8, output_dir: "/path/to/project/media" })
82
+
83
+ # 3. Export DAW stems (pads each clip to full video length with silence before/after)
84
+ export_stems({ project_dir: "/path/to/project", video_duration: 120.5 })
85
+ ```
86
+
87
+ The exported WAVs drop directly into any DAW (Reaper, Logic, Pro Tools, etc.) at the correct timecode — no manual alignment needed.
88
+
89
+ ## Project structure
90
+
91
+ ```
92
+ ~/Documents/Proyectos SPOTTER/<timestamp>_<name>/
93
+ ├── analysis.json # Full cue sheet (cues array + narrative)
94
+ ├── criterio.txt # Director's intent (if provided)
95
+ ├── frames/ # Extracted JPGs used for analysis
96
+ ├── media/ # Generated/downloaded audio files
97
+ └── export/
98
+ ├── cue_sheet.csv # Human-readable cue sheet
99
+ └── AMB_cue_001_*.wav # DAW-synchronized stems
100
+ ```
101
+
102
+ ## Use without the Electron app
103
+
104
+ SPOTTER MCP works standalone — you don't need the Electron app. The Electron app is for step-by-step manual control; the MCP server is for fully automated pipeline use from Claude Code.
@@ -0,0 +1,14 @@
1
+ export interface ProviderConfig {
2
+ apiKey?: string;
3
+ endpoint?: string;
4
+ enabled: boolean;
5
+ type?: 'sfx-generate' | 'sfx-search' | 'both';
6
+ }
7
+ export interface SpotterConfig {
8
+ claudePath: string;
9
+ providers: Record<string, ProviderConfig>;
10
+ }
11
+ export declare function loadConfig(): SpotterConfig;
12
+ export declare function saveConfig(config: SpotterConfig): void;
13
+ export declare function getKey(name: string): string | null;
14
+ export declare function setKey(name: string, apiKey: string): void;
package/dist/config.js ADDED
@@ -0,0 +1,36 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const CONFIG_DIR = path.join(os.homedir(), '.spotter');
5
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
6
+ const DEFAULTS = {
7
+ claudePath: 'claude',
8
+ providers: {
9
+ elevenlabs: { enabled: false, type: 'sfx-generate' },
10
+ freesound: { enabled: false, type: 'sfx-search' },
11
+ }
12
+ };
13
+ export function loadConfig() {
14
+ if (!fs.existsSync(CONFIG_PATH))
15
+ return DEFAULTS;
16
+ try {
17
+ const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
18
+ return { ...DEFAULTS, ...raw, providers: { ...DEFAULTS.providers, ...raw.providers } };
19
+ }
20
+ catch {
21
+ return DEFAULTS;
22
+ }
23
+ }
24
+ export function saveConfig(config) {
25
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
27
+ }
28
+ export function getKey(name) {
29
+ const cfg = loadConfig();
30
+ return cfg.providers[name]?.apiKey ?? null;
31
+ }
32
+ export function setKey(name, apiKey) {
33
+ const cfg = loadConfig();
34
+ cfg.providers[name] = { ...(cfg.providers[name] ?? { enabled: true }), apiKey };
35
+ saveConfig(cfg);
36
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { analyzeVideo } from './tools/analyze.js';
6
+ import { exportStems } from './tools/export.js';
7
+ import { getProvider, listProviders } from './providers/registry.js';
8
+ import { loadConfig, setKey, saveConfig } from './config.js';
9
+ import path from 'path';
10
+ import fs from 'fs';
11
+ const server = new Server({ name: 'spotter', version: '0.1.0' }, { capabilities: { tools: {} } });
12
+ // ─── Tool definitions ────────────────────────────────────────────────────────
13
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
14
+ tools: [
15
+ {
16
+ name: 'analyze_video',
17
+ description: 'Analyze a video file for sound design. Extracts frames, runs AI analysis, returns a complete cue sheet with sound descriptions and timecodes.',
18
+ inputSchema: {
19
+ type: 'object',
20
+ properties: {
21
+ video_path: { type: 'string', description: 'Absolute path to the video file (.mp4, .mov)' },
22
+ project_dir: { type: 'string', description: 'Optional: directory to store project files. Defaults to ~/Documents/Proyectos SPOTTER/<timestamp>_<name>' },
23
+ criterio: { type: 'string', description: "Optional: director's intent / sonic references (free text, passed to the AI)" },
24
+ },
25
+ required: ['video_path']
26
+ }
27
+ },
28
+ {
29
+ name: 'generate_sfx',
30
+ description: 'Generate a sound effect audio file from a text prompt using ElevenLabs or a configured provider.',
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ prompt: { type: 'string', description: 'Sound description in English (e.g. "heavy rain on metal roof, distant thunder rumble")' },
35
+ duration_seconds: { type: 'number', description: 'Duration in seconds (0.5–22). Default: 5', default: 5 },
36
+ output_dir: { type: 'string', description: 'Directory to save the generated audio file' },
37
+ provider: { type: 'string', description: 'Provider name (elevenlabs, or any configured custom provider). Default: first enabled provider' },
38
+ },
39
+ required: ['prompt', 'output_dir']
40
+ }
41
+ },
42
+ {
43
+ name: 'search_sfx',
44
+ description: 'Search for sound effects on Freesound or a configured search provider.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ query: { type: 'string', description: 'Search query in English' },
49
+ max_results: { type: 'number', description: 'Maximum results to return (1–20). Default: 10', default: 10 },
50
+ provider: { type: 'string', description: 'Search provider. Default: freesound' },
51
+ },
52
+ required: ['query']
53
+ }
54
+ },
55
+ {
56
+ name: 'export_stems',
57
+ description: 'Export DAW-synchronized WAV stems + CSV cue sheet from a SPOTTER project. Each WAV starts at the correct timecode position for direct import into any DAW.',
58
+ inputSchema: {
59
+ type: 'object',
60
+ properties: {
61
+ project_dir: { type: 'string', description: 'Path to the SPOTTER project directory (must contain analysis.json and media/ folder)' },
62
+ video_duration: { type: 'number', description: 'Video duration in seconds (used to pad WAV files to full length)' },
63
+ cues: { type: 'string', description: 'Optional: JSON array of cue objects with updated positions. If omitted, reads from project analysis.json' },
64
+ },
65
+ required: ['project_dir', 'video_duration']
66
+ }
67
+ },
68
+ {
69
+ name: 'list_providers',
70
+ description: 'List all configured SFX providers and their status.',
71
+ inputSchema: { type: 'object', properties: {} }
72
+ },
73
+ {
74
+ name: 'configure_provider',
75
+ description: 'Add or update an SFX provider API key or endpoint. Stored in ~/.spotter/config.json.',
76
+ inputSchema: {
77
+ type: 'object',
78
+ properties: {
79
+ provider: { type: 'string', description: 'Provider name (e.g. elevenlabs, freesound, or a custom name)' },
80
+ api_key: { type: 'string', description: 'API key for the provider' },
81
+ endpoint: { type: 'string', description: 'Optional: custom REST endpoint for generate (POST with {prompt, duration_seconds} body)' },
82
+ enabled: { type: 'boolean', description: 'Enable or disable this provider', default: true },
83
+ },
84
+ required: ['provider']
85
+ }
86
+ },
87
+ ]
88
+ }));
89
+ // ─── Tool handlers ────────────────────────────────────────────────────────────
90
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
91
+ const { name, arguments: args } = request.params;
92
+ if (!args)
93
+ throw new McpError(ErrorCode.InvalidParams, 'Missing arguments');
94
+ try {
95
+ switch (name) {
96
+ case 'analyze_video': {
97
+ const result = await analyzeVideo({
98
+ videoPath: args.video_path,
99
+ projectDir: args.project_dir,
100
+ criterio: args.criterio,
101
+ });
102
+ const analysis = result.analysis;
103
+ return {
104
+ content: [{
105
+ type: 'text',
106
+ text: JSON.stringify({
107
+ projectDir: result.projectDir,
108
+ analysisPath: result.analysisPath,
109
+ cueCount: (analysis.cues ?? []).length,
110
+ narrative: analysis.narrative,
111
+ cues: analysis.cues,
112
+ }, null, 2)
113
+ }]
114
+ };
115
+ }
116
+ case 'generate_sfx': {
117
+ const provider = getProvider(args.provider);
118
+ if (!provider.canGenerate || !provider.generate) {
119
+ throw new McpError(ErrorCode.InvalidParams, `Provider "${provider.name}" does not support generation`);
120
+ }
121
+ const result = await provider.generate(args.prompt, args.duration_seconds ?? 5, args.output_dir);
122
+ return {
123
+ content: [{
124
+ type: 'text',
125
+ text: JSON.stringify(result, null, 2)
126
+ }]
127
+ };
128
+ }
129
+ case 'search_sfx': {
130
+ const provider = getProvider(args.provider ?? 'freesound');
131
+ if (!provider.canSearch || !provider.search) {
132
+ throw new McpError(ErrorCode.InvalidParams, `Provider "${provider.name}" does not support search`);
133
+ }
134
+ const results = await provider.search(args.query, args.max_results ?? 10);
135
+ return {
136
+ content: [{
137
+ type: 'text',
138
+ text: JSON.stringify(results, null, 2)
139
+ }]
140
+ };
141
+ }
142
+ case 'export_stems': {
143
+ const projectDir = args.project_dir;
144
+ const videoDuration = args.video_duration;
145
+ let cues;
146
+ if (args.cues) {
147
+ cues = JSON.parse(args.cues);
148
+ }
149
+ else {
150
+ const analysisPath = path.join(projectDir, 'analysis.json');
151
+ if (!fs.existsSync(analysisPath)) {
152
+ throw new McpError(ErrorCode.InvalidParams, `No analysis.json found in ${projectDir}`);
153
+ }
154
+ const analysis = JSON.parse(fs.readFileSync(analysisPath, 'utf-8'));
155
+ cues = analysis.cues ?? [];
156
+ }
157
+ const result = await exportStems(projectDir, cues, videoDuration);
158
+ return {
159
+ content: [{
160
+ type: 'text',
161
+ text: JSON.stringify(result, null, 2)
162
+ }]
163
+ };
164
+ }
165
+ case 'list_providers': {
166
+ return {
167
+ content: [{
168
+ type: 'text',
169
+ text: JSON.stringify(listProviders(), null, 2)
170
+ }]
171
+ };
172
+ }
173
+ case 'configure_provider': {
174
+ const providerName = args.provider;
175
+ const cfg = loadConfig();
176
+ cfg.providers[providerName] = {
177
+ ...(cfg.providers[providerName] ?? {}),
178
+ enabled: args.enabled ?? true,
179
+ ...(args.api_key ? { apiKey: args.api_key } : {}),
180
+ ...(args.endpoint ? { endpoint: args.endpoint, type: 'sfx-generate' } : {}),
181
+ };
182
+ saveConfig(cfg);
183
+ // Also mirror to setKey for convenience
184
+ if (args.api_key)
185
+ setKey(providerName, args.api_key);
186
+ return {
187
+ content: [{
188
+ type: 'text',
189
+ text: `Provider "${providerName}" configured. Config saved to ~/.spotter/config.json`
190
+ }]
191
+ };
192
+ }
193
+ default:
194
+ throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
195
+ }
196
+ }
197
+ catch (e) {
198
+ if (e instanceof McpError)
199
+ throw e;
200
+ throw new McpError(ErrorCode.InternalError, e instanceof Error ? e.message : String(e));
201
+ }
202
+ });
203
+ // ─── Start ────────────────────────────────────────────────────────────────────
204
+ async function main() {
205
+ const transport = new StdioServerTransport();
206
+ await server.connect(transport);
207
+ // stderr is safe for logging (doesn't pollute MCP stdio)
208
+ process.stderr.write('SPOTTER MCP server running\n');
209
+ }
210
+ main().catch(err => {
211
+ process.stderr.write(`Fatal: ${err.message}\n`);
212
+ process.exit(1);
213
+ });
@@ -0,0 +1,30 @@
1
+ export interface SfxGenerateResult {
2
+ filePath: string;
3
+ provider: string;
4
+ prompt: string;
5
+ durationSeconds: number;
6
+ }
7
+ export interface SfxSearchResult {
8
+ id: string;
9
+ name: string;
10
+ duration: number;
11
+ username: string;
12
+ license: string;
13
+ previewUrl: string;
14
+ sourceUrl: string;
15
+ provider: string;
16
+ }
17
+ export interface SfxProvider {
18
+ name: string;
19
+ canGenerate: boolean;
20
+ canSearch: boolean;
21
+ generate?(prompt: string, durationSeconds: number, outputDir: string): Promise<SfxGenerateResult>;
22
+ search?(query: string, maxResults?: number): Promise<SfxSearchResult[]>;
23
+ }
24
+ export declare function getProvider(name?: string): SfxProvider;
25
+ export declare function listProviders(): Array<{
26
+ name: string;
27
+ enabled: boolean;
28
+ hasKey: boolean;
29
+ type: string;
30
+ }>;
@@ -0,0 +1,115 @@
1
+ import { loadConfig } from '../config.js';
2
+ // ─── ElevenLabs provider ─────────────────────────────────────────────────────
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import crypto from 'crypto';
6
+ const elevenLabsProvider = {
7
+ name: 'elevenlabs',
8
+ canGenerate: true,
9
+ canSearch: false,
10
+ async generate(prompt, durationSeconds, outputDir) {
11
+ const cfg = loadConfig();
12
+ const key = cfg.providers['elevenlabs']?.apiKey;
13
+ if (!key)
14
+ throw new Error('ElevenLabs API key not configured. Run: spotter-mcp config set elevenlabs.apiKey YOUR_KEY');
15
+ const res = await fetch('https://api.elevenlabs.io/v1/sound-generation', {
16
+ method: 'POST',
17
+ headers: { 'xi-api-key': key, 'Content-Type': 'application/json' },
18
+ body: JSON.stringify({ text: prompt, duration_seconds: durationSeconds, prompt_influence: 0.6 })
19
+ });
20
+ if (!res.ok) {
21
+ const body = await res.text();
22
+ throw new Error(`ElevenLabs error ${res.status}: ${body.slice(0, 200)}`);
23
+ }
24
+ fs.mkdirSync(outputDir, { recursive: true });
25
+ const slug = prompt.slice(0, 32).replace(/[^a-zA-Z0-9]/g, '_');
26
+ const id = crypto.randomBytes(4).toString('hex');
27
+ const filename = `el_${slug}_${id}.mp3`;
28
+ const filePath = path.join(outputDir, filename);
29
+ fs.writeFileSync(filePath, Buffer.from(await res.arrayBuffer()));
30
+ return { filePath, provider: 'elevenlabs', prompt, durationSeconds };
31
+ }
32
+ };
33
+ // ─── Freesound provider ──────────────────────────────────────────────────────
34
+ const freesoundProvider = {
35
+ name: 'freesound',
36
+ canGenerate: false,
37
+ canSearch: true,
38
+ async search(query, maxResults = 12) {
39
+ const cfg = loadConfig();
40
+ const key = cfg.providers['freesound']?.apiKey;
41
+ if (!key)
42
+ throw new Error('Freesound API key not configured. Run: spotter-mcp config set freesound.apiKey YOUR_KEY');
43
+ const params = new URLSearchParams({
44
+ query, token: key,
45
+ fields: 'id,name,duration,previews,license,username,url',
46
+ page_size: String(maxResults),
47
+ filter: 'duration:[1 TO 60]',
48
+ sort: 'score'
49
+ });
50
+ const res = await fetch(`https://freesound.org/apiv2/search/text/?${params}`);
51
+ if (!res.ok)
52
+ throw new Error(`Freesound error ${res.status}`);
53
+ const data = await res.json();
54
+ return (data.results ?? []).map(r => ({
55
+ id: String(r.id),
56
+ name: r.name,
57
+ duration: r.duration,
58
+ username: r.username,
59
+ license: r.license,
60
+ previewUrl: r.previews?.['preview-hq-mp3'] ?? '',
61
+ sourceUrl: r.url,
62
+ provider: 'freesound'
63
+ }));
64
+ }
65
+ };
66
+ // ─── Custom provider (generic REST) ─────────────────────────────────────────
67
+ function buildCustomProvider(name) {
68
+ return {
69
+ name,
70
+ canGenerate: true,
71
+ canSearch: false,
72
+ async generate(prompt, durationSeconds, outputDir) {
73
+ const cfg = loadConfig();
74
+ const pc = cfg.providers[name];
75
+ if (!pc?.endpoint)
76
+ throw new Error(`Provider "${name}" has no endpoint configured`);
77
+ if (!pc.apiKey)
78
+ throw new Error(`Provider "${name}" has no apiKey configured`);
79
+ const res = await fetch(pc.endpoint, {
80
+ method: 'POST',
81
+ headers: { 'Authorization': `Bearer ${pc.apiKey}`, 'Content-Type': 'application/json' },
82
+ body: JSON.stringify({ prompt, duration_seconds: durationSeconds })
83
+ });
84
+ if (!res.ok)
85
+ throw new Error(`${name} error ${res.status}: ${(await res.text()).slice(0, 200)}`);
86
+ const contentType = res.headers.get('content-type') ?? '';
87
+ const ext = contentType.includes('wav') ? 'wav' : contentType.includes('ogg') ? 'ogg' : 'mp3';
88
+ fs.mkdirSync(outputDir, { recursive: true });
89
+ const slug = prompt.slice(0, 32).replace(/[^a-zA-Z0-9]/g, '_');
90
+ const id = crypto.randomBytes(4).toString('hex');
91
+ const filePath = path.join(outputDir, `${name}_${slug}_${id}.${ext}`);
92
+ fs.writeFileSync(filePath, Buffer.from(await res.arrayBuffer()));
93
+ return { filePath, provider: name, prompt, durationSeconds };
94
+ }
95
+ };
96
+ }
97
+ // ─── Registry ────────────────────────────────────────────────────────────────
98
+ const BUILT_IN = {
99
+ elevenlabs: elevenLabsProvider,
100
+ freesound: freesoundProvider,
101
+ };
102
+ export function getProvider(name) {
103
+ const cfg = loadConfig();
104
+ const target = name ?? Object.keys(cfg.providers).find(k => cfg.providers[k].enabled) ?? 'elevenlabs';
105
+ return BUILT_IN[target] ?? buildCustomProvider(target);
106
+ }
107
+ export function listProviders() {
108
+ const cfg = loadConfig();
109
+ return Object.entries(cfg.providers).map(([name, pc]) => ({
110
+ name,
111
+ enabled: pc.enabled,
112
+ hasKey: !!pc.apiKey,
113
+ type: pc.type ?? (BUILT_IN[name]?.canGenerate ? 'sfx-generate' : 'sfx-search')
114
+ }));
115
+ }
@@ -0,0 +1,11 @@
1
+ export interface AnalyzeOptions {
2
+ videoPath: string;
3
+ projectDir?: string;
4
+ criterio?: string;
5
+ }
6
+ export interface AnalysisResult {
7
+ projectDir: string;
8
+ analysisPath: string;
9
+ analysis: object;
10
+ }
11
+ export declare function analyzeVideo(opts: AnalyzeOptions): Promise<AnalysisResult>;
@@ -0,0 +1,142 @@
1
+ import ffmpegPath from 'ffmpeg-static';
2
+ import ffmpeg from 'fluent-ffmpeg';
3
+ import { spawn } from 'child_process';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import os from 'os';
7
+ import { loadConfig } from '../config.js';
8
+ ffmpeg.setFfmpegPath(ffmpegPath);
9
+ function extractFrame(videoPath, tc, outputPath) {
10
+ return new Promise((resolve, reject) => {
11
+ ffmpeg(videoPath)
12
+ .seekInput(tc)
13
+ .frames(1)
14
+ .outputOptions(['-q:v 4', '-vf scale=960:-2'])
15
+ .output(outputPath)
16
+ .on('end', () => resolve())
17
+ .on('error', (err) => reject(err))
18
+ .run();
19
+ });
20
+ }
21
+ function getVideoDuration(videoPath) {
22
+ return new Promise((resolve, reject) => {
23
+ ffmpeg.ffprobe(videoPath, (err, data) => {
24
+ if (err)
25
+ reject(err);
26
+ else
27
+ resolve(data.format.duration ?? 0);
28
+ });
29
+ });
30
+ }
31
+ function buildPrompt(duration, criterio, frameFiles) {
32
+ const now = new Date().toISOString();
33
+ const frameList = frameFiles.map(f => {
34
+ const tcMatch = path.basename(f).match(/_(\d+)s\.jpg$/);
35
+ const tc = tcMatch ? parseInt(tcMatch[1]) : 0;
36
+ return ` - ${f} (tc: ${tc}s)`;
37
+ }).join('\n');
38
+ return `You are a professional sound designer analyzing a video project for sound design spotting.
39
+
40
+ VIDEO DURATION: ${duration.toFixed(1)} seconds
41
+ ${criterio ? `DIRECTOR'S INTENT: ${criterio}` : ''}
42
+
43
+ FRAMES (${frameFiles.length} total — use the Read tool to view each image):
44
+ ${frameList}
45
+
46
+ TASK:
47
+ 1. Use the Read tool to read and visually analyze EVERY frame listed above.
48
+ 2. Generate 15–30 sound design cues covering the full ${duration.toFixed(1)}s.
49
+ 3. Output ONLY a raw JSON object to stdout — no markdown fences, no prose, pure JSON.
50
+
51
+ JSON SCHEMA (ALL text content in English):
52
+ {
53
+ "narrative": {
54
+ "synopsis": "1–2 sentences: what happens visually and narratively",
55
+ "mood": "dominant emotional tone and subtext",
56
+ "pacing": "visual/narrative rhythm description",
57
+ "keyMoments": [{"tc": 12.5, "description": "moment description for sound"}],
58
+ "soundscapeNotes": "acoustic spaces, sound worlds, reference layers"
59
+ },
60
+ "cues": [
61
+ {
62
+ "id": "cue_001",
63
+ "tc_in": 0.0,
64
+ "tc_out": 15.0,
65
+ "track": "AMB",
66
+ "description": "Concrete sound description in English — feeds into Freesound search and ElevenLabs SFX generation",
67
+ "mood": "Emotional tone of this cue in English",
68
+ "priority": "A",
69
+ "notes": "Optional production notes in English",
70
+ "status": "pending"
71
+ }
72
+ ],
73
+ "analyzedAt": "${now}",
74
+ "modelUsed": "claude-sonnet-4-6"
75
+ }
76
+
77
+ TRACK RULES:
78
+ - AMB: ambiences, atmospheres, room tone
79
+ - FOLEY: physical presence (footsteps, cloth, contact, objects)
80
+ - DISEÑO: non-naturalistic sound design (emotional hits, transitions, abstract elements)
81
+ - PRÁCTICO: diegetic practical sources (cars, TV, phone, machinery)
82
+
83
+ PRIORITY: A=essential / B=enriches / C=optional texture
84
+ Cues from different tracks CAN overlap in time. status always "pending".
85
+ tc_in/tc_out never exceed ${duration.toFixed(1)}.
86
+
87
+ CRITICAL: your entire response must be ONLY the JSON object. Start with { end with }.`;
88
+ }
89
+ export async function analyzeVideo(opts) {
90
+ const { videoPath, criterio = '' } = opts;
91
+ if (!fs.existsSync(videoPath))
92
+ throw new Error(`Video not found: ${videoPath}`);
93
+ // Determine project directory
94
+ const projectDir = opts.projectDir ?? path.join(os.homedir(), 'Documents', 'Proyectos SPOTTER', `${Date.now()}_${path.basename(videoPath, path.extname(videoPath)).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 40)}`);
95
+ const framesDir = path.join(projectDir, 'frames');
96
+ fs.mkdirSync(framesDir, { recursive: true });
97
+ // Extract frames
98
+ const duration = await getVideoDuration(videoPath);
99
+ const targetCount = Math.min(20, Math.max(4, Math.ceil(duration / 8)));
100
+ const interval = duration / targetCount;
101
+ const frameFiles = [];
102
+ for (let i = 0; i < targetCount; i++) {
103
+ const tc = Math.min(i * interval, duration - 0.5);
104
+ const outPath = path.join(framesDir, `frame_${String(i).padStart(3, '0')}_${Math.round(tc)}s.jpg`);
105
+ if (!fs.existsSync(outPath))
106
+ await extractFrame(videoPath, tc, outPath);
107
+ frameFiles.push(outPath);
108
+ }
109
+ // Run claude CLI
110
+ const cfg = loadConfig();
111
+ const claudePath = cfg.claudePath || 'claude';
112
+ const prompt = buildPrompt(duration, criterio, frameFiles);
113
+ const result = await new Promise((resolve, reject) => {
114
+ const env = { ...process.env, PATH: `/usr/local/bin:/opt/homebrew/bin:${process.env.PATH || ''}` };
115
+ const proc = spawn(claudePath, ['-p', prompt, '--output-format', 'text', '--allowedTools', 'Read'], { env });
116
+ let stdout = '';
117
+ let stderr = '';
118
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
119
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
120
+ proc.on('close', code => {
121
+ if (code !== 0)
122
+ reject(new Error(`Claude exited ${code}\n${stderr.slice(-300)}`));
123
+ else
124
+ resolve(stdout);
125
+ });
126
+ proc.on('error', err => reject(new Error(`Claude not found at "${claudePath}": ${err.message}`)));
127
+ });
128
+ const jsonMatch = result.match(/\{[\s\S]*\}/);
129
+ if (!jsonMatch)
130
+ throw new Error(`Claude returned no JSON.\nOutput: ${result.slice(0, 300)}`);
131
+ const analysis = JSON.parse(jsonMatch[0]);
132
+ const analysisPath = path.join(projectDir, 'analysis.json');
133
+ fs.writeFileSync(analysisPath, JSON.stringify(analysis, null, 2), 'utf-8');
134
+ // Save criterio if provided
135
+ if (criterio)
136
+ fs.writeFileSync(path.join(projectDir, 'criterio.txt'), criterio, 'utf-8');
137
+ // Copy video reference
138
+ const destVideo = path.join(projectDir, path.basename(videoPath));
139
+ if (!fs.existsSync(destVideo))
140
+ fs.copyFileSync(videoPath, destVideo);
141
+ return { projectDir, analysisPath, analysis };
142
+ }
@@ -0,0 +1,19 @@
1
+ export interface ExportCue {
2
+ id: string;
3
+ tc_in: number;
4
+ tc_out: number;
5
+ track: string;
6
+ description: string;
7
+ mood?: string;
8
+ priority: string;
9
+ audioFile?: string;
10
+ startTime?: number;
11
+ endTime?: number;
12
+ }
13
+ export interface ExportResult {
14
+ exportDir: string;
15
+ wavFiles: string[];
16
+ csvPath: string;
17
+ errors: string[];
18
+ }
19
+ export declare function exportStems(projectDir: string, cues: ExportCue[], videoDuration: number): Promise<ExportResult>;
@@ -0,0 +1,61 @@
1
+ import { execFile } from 'child_process';
2
+ import ffmpegPath from 'ffmpeg-static';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ function runFfmpeg(args) {
6
+ return new Promise((resolve, reject) => {
7
+ execFile(ffmpegPath, args, (err, _stdout, stderr) => {
8
+ if (err)
9
+ reject(new Error(stderr || err.message));
10
+ else
11
+ resolve();
12
+ });
13
+ });
14
+ }
15
+ export async function exportStems(projectDir, cues, videoDuration) {
16
+ const exportDir = path.join(projectDir, 'export');
17
+ const mediaDir = path.join(projectDir, 'media');
18
+ fs.mkdirSync(exportDir, { recursive: true });
19
+ const exported = [];
20
+ const errors = [];
21
+ for (const cue of cues) {
22
+ if (!cue.audioFile)
23
+ continue;
24
+ const src = path.join(mediaDir, cue.audioFile);
25
+ if (!fs.existsSync(src)) {
26
+ errors.push(`${cue.id}: audio file not found (${cue.audioFile})`);
27
+ continue;
28
+ }
29
+ const tcIn = cue.startTime ?? cue.tc_in;
30
+ const delayMs = Math.round(tcIn * 1000);
31
+ const safeName = cue.description.slice(0, 36).replace(/[^a-zA-Z0-9_\- ]/g, '').replace(/\s+/g, '_');
32
+ const outName = `${cue.track}_${cue.id}_${safeName}.wav`;
33
+ const outPath = path.join(exportDir, outName);
34
+ try {
35
+ await runFfmpeg([
36
+ '-y', '-i', src,
37
+ '-af', `adelay=${delayMs}|${delayMs},apad=whole_dur=${videoDuration}`,
38
+ '-t', String(videoDuration),
39
+ '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s16le',
40
+ outPath
41
+ ]);
42
+ exported.push(outName);
43
+ }
44
+ catch (e) {
45
+ errors.push(`${cue.id}: ${e instanceof Error ? e.message.slice(0, 80) : 'error'}`);
46
+ }
47
+ }
48
+ // CSV cue sheet
49
+ const header = 'ID,TC_IN,TC_OUT,DURATION,TRACK,PRIORITY,DESCRIPTION,MOOD,AUDIO_FILE,WAV_EXPORT';
50
+ const rows = cues.map(c => {
51
+ const tcIn = (c.startTime ?? c.tc_in).toFixed(3);
52
+ const tcOut = (c.endTime ?? c.tc_out).toFixed(3);
53
+ const dur = (parseFloat(tcOut) - parseFloat(tcIn)).toFixed(3);
54
+ const esc = (s) => `"${(s ?? '').replace(/"/g, '""')}"`;
55
+ const wav = exported.find(f => f.includes(c.id)) ?? '';
56
+ return [c.id, tcIn, tcOut, dur, c.track, c.priority, esc(c.description), esc(c.mood ?? ''), esc(c.audioFile ?? ''), esc(wav)].join(',');
57
+ });
58
+ const csvPath = path.join(exportDir, 'cue_sheet.csv');
59
+ fs.writeFileSync(csvPath, [header, ...rows].join('\n'), 'utf-8');
60
+ return { exportDir, wavFiles: exported, csvPath, errors };
61
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "lemolabs-spotter-mcp",
3
+ "version": "0.1.0",
4
+ "description": "AI-assisted sound design spotting — MCP server for Claude Code",
5
+ "keywords": ["mcp", "sound-design", "spotting", "ai", "claude"],
6
+ "author": "Lemö Labs",
7
+ "license": "MIT",
8
+ "type": "module",
9
+ "bin": {
10
+ "spotter-mcp": "./dist/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "node --loader ts-node/esm src/index.ts",
15
+ "start": "node dist/index.js",
16
+ "prepublishOnly": "npm run build"
17
+ },
18
+ "dependencies": {
19
+ "@modelcontextprotocol/sdk": "^1.0.4",
20
+ "ffmpeg-static": "^5.2.0",
21
+ "fluent-ffmpeg": "^2.1.2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/fluent-ffmpeg": "^2.1.24",
25
+ "@types/node": "^20.0.0",
26
+ "ts-node": "^10.9.0",
27
+ "typescript": "^5.3.0"
28
+ }
29
+ }