#
tokens: 6861/50000 8/8 files
lines: on (toggle) GitHub
raw markdown copy reset
# Directory Structure

```
├── .gitignore
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── src
│   ├── index.ts
│   ├── tools
│   │   ├── definitions.ts
│   │   └── handlers.ts
│   └── utils
│       ├── ffmpeg.ts
│       └── file.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | node_modules/
 2 | build/
 3 | *.log
 4 | .env*
 5 | *.mp3
 6 | *.jpg
 7 | *.png
 8 | *.mp4
 9 | *.avi
10 | 
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "compilerOptions": {
 3 |     "target": "ES2022",
 4 |     "module": "Node16",
 5 |     "moduleResolution": "Node16",
 6 |     "outDir": "./build",
 7 |     "rootDir": "./src",
 8 |     "strict": true,
 9 |     "esModuleInterop": true,
10 |     "skipLibCheck": true,
11 |     "forceConsistentCasingInFileNames": true
12 |   },
13 |   "include": ["src/**/*"],
14 |   "exclude": ["node_modules"]
15 | }
16 | 
```

--------------------------------------------------------------------------------
/src/utils/file.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { existsSync } from "fs";
 2 | import { dirname } from "path";
 3 | import { mkdir } from "fs/promises";
 4 | 
 5 | /**
 6 |  * Helper function to ensure a directory exists
 7 |  */
 8 | export async function ensureDirectoryExists(filePath: string): Promise<void> {
 9 |   const dir = dirname(filePath);
10 |   try {
11 |     await mkdir(dir, { recursive: true });
12 |   } catch (error) {
13 |     // Directory already exists or cannot be created
14 |     if ((error as any).code !== 'EEXIST') {
15 |       throw error;
16 |     }
17 |   }
18 | }
19 | 
20 | /**
21 |  * Helper function to validate file path
22 |  */
23 | export function validatePath(path: string, isInput: boolean = false): string {
24 |   if (!path) {
25 |     throw new Error("File path is required");
26 |   }
27 |   
28 |   if (isInput && !existsSync(path)) {
29 |     throw new Error(`Input file does not exist: ${path}`);
30 |   }
31 |   
32 |   return path;
33 | }
34 | 
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
 1 | {
 2 |   "name": "@sworddut/mcp-ffmpeg-helper",
 3 |   "version": "0.1.0",
 4 |   "description": "A Model Context Protocol (MCP) helper for FFmpeg video processing operations",
 5 |   "type": "module",
 6 |   "bin": {
 7 |     "mcp-ffmpeg-helper": "./build/index.js"
 8 |   },
 9 |   "files": [
10 |     "build"
11 |   ],
12 |   "scripts": {
13 |     "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"",
14 |     "prepare": "npm run build",
15 |     "watch": "tsc --watch",
16 |     "inspector": "npx @modelcontextprotocol/inspector build/index.js"
17 |   },
18 |   "dependencies": {
19 |     "@modelcontextprotocol/sdk": "0.6.0",
20 |     "ffmpeg": "^0.0.4"
21 |   },
22 |   "devDependencies": {
23 |     "@types/node": "^20.11.24",
24 |     "typescript": "^5.3.3"
25 |   },
26 |   "keywords": [
27 |     "ffmpeg",
28 |     "video",
29 |     "mcp",
30 |     "model-context-protocol",
31 |     "video-processing",
32 |     "watermark",
33 |     "trim",
34 |     "convert"
35 |   ],
36 |   "author": "Your Name",
37 |   "license": "MIT",
38 |   "repository": {
39 |     "type": "git",
40 |     "url": "https://github.com/yourusername/mcp-ffmpeg-helper.git"
41 |   },
42 |   "bugs": {
43 |     "url": "https://github.com/yourusername/mcp-ffmpeg-helper/issues"
44 |   },
45 |   "homepage": "https://github.com/yourusername/mcp-ffmpeg-helper#readme",
46 |   "engines": {
47 |     "node": ">=14.0.0"
48 |   }
49 | }
50 | 
```

--------------------------------------------------------------------------------
/src/utils/ffmpeg.ts:
--------------------------------------------------------------------------------

```typescript
 1 | import { exec } from "child_process";
 2 | import { promisify } from "util";
 3 | import { validatePath } from "./file.js";
 4 | 
 5 | const execPromise = promisify(exec);
 6 | 
 7 | /**
 8 |  * Helper function to run FFmpeg commands with better error handling
 9 |  */
10 | export async function runFFmpegCommand(command: string): Promise<string> {
11 |   try {
12 |     console.log(`Running FFmpeg command: ffmpeg ${command}`);
13 |     const { stdout, stderr } = await execPromise(`ffmpeg ${command}`);
14 |     return stdout || stderr;
15 |   } catch (error: any) {
16 |     console.error("FFmpeg error:", error.message);
17 |     if (error.stderr) {
18 |       return error.stderr;
19 |     }
20 |     throw new Error(`FFmpeg error: ${error.message}`);
21 |   }
22 | }
23 | 
24 | /**
25 |  * Helper function to get information about a video file
26 |  */
27 | export async function getVideoInfo(filePath: string): Promise<string> {
28 |   try {
29 |     validatePath(filePath, true);
30 |     console.log(`Getting video info for: ${filePath}`);
31 |     const { stdout, stderr } = await execPromise(`ffprobe -v error -show_format -show_streams -print_format json "${filePath}"`);
32 |     return stdout || stderr;
33 |   } catch (error: any) {
34 |     console.error("FFprobe error:", error.message);
35 |     if (error.stderr) {
36 |       return error.stderr;
37 |     }
38 |     throw new Error(`FFprobe error: ${error.message}`);
39 |   }
40 | }
41 | 
```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
 1 | #!/usr/bin/env node
 2 | 
 3 | /**
 4 |  * FFmpeg Helper MCP Server
 5 |  * A simple MCP server that provides FFmpeg functionality through tools.
 6 |  * It allows video operations like:
 7 |  * - Getting video information
 8 |  * - Converting video formats
 9 |  * - Extracting audio from video
10 |  * - Creating video from image sequences
11 |  * - Trimming videos
12 |  * - Adding watermarks
13 |  * - Applying filters
14 |  */
15 | 
16 | import { Server } from "@modelcontextprotocol/sdk/server/index.js";
17 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18 | import {
19 |   CallToolRequestSchema,
20 |   ListToolsRequestSchema,
21 | } from "@modelcontextprotocol/sdk/types.js";
22 | 
23 | // Import our modularized code
24 | import { toolDefinitions } from "./tools/definitions.js";
25 | import { handleToolCall } from "./tools/handlers.js";
26 | 
27 | /**
28 |  * Create an MCP server with capabilities for tools to interact with FFmpeg
29 |  */
30 | const server = new Server(
31 |   {
32 |     name: "mcp-ffmpeg-helper",
33 |     version: "0.2.0",
34 |   },
35 |   {
36 |     capabilities: {
37 |       tools: {},
38 |     },
39 |   }
40 | );
41 | 
42 | /**
43 |  * Handler that lists available tools.
44 |  * Exposes FFmpeg-related tools for video operations.
45 |  */
46 | server.setRequestHandler(ListToolsRequestSchema, async () => {
47 |   return {
48 |     tools: toolDefinitions
49 |   };
50 | });
51 | 
52 | /**
53 |  * Handler for FFmpeg tools.
54 |  * Implements various video operations using FFmpeg.
55 |  */
56 | server.setRequestHandler(CallToolRequestSchema, async (request) => {
57 |   try {
58 |     return await handleToolCall(request.params.name, request.params.arguments);
59 |   } catch (error: any) {
60 |     console.error("Tool execution error:", error.message);
61 |     return {
62 |       content: [{
63 |         type: "text",
64 |         text: `Error: ${error.message}`
65 |       }]
66 |     };
67 |   }
68 | });
69 | 
70 | /**
71 |  * Start the server using stdio transport.
72 |  * This allows the server to communicate via standard input/output streams.
73 |  */
74 | async function main() {
75 |   console.log("Starting MCP FFmpeg Helper server...");
76 |   const transport = new StdioServerTransport();
77 |   await server.connect(transport);
78 |   console.log("MCP FFmpeg Helper server connected and ready");
79 | }
80 | 
81 | main().catch((error) => {
82 |   console.error("Server error:", error);
83 |   process.exit(1);
84 | });
85 | 
```

--------------------------------------------------------------------------------
/src/tools/definitions.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /**
  2 |  * Tool definitions for FFmpeg operations
  3 |  * Defines the available tools and their input schemas
  4 |  */
  5 | export const toolDefinitions = [
  6 |   {
  7 |     name: "get_video_info",
  8 |     description: "Get detailed information about a video file",
  9 |     inputSchema: {
 10 |       type: "object",
 11 |       properties: {
 12 |         filePath: {
 13 |           type: "string",
 14 |           description: "Path to the video file"
 15 |         }
 16 |       },
 17 |       required: ["filePath"]
 18 |     }
 19 |   },
 20 |   {
 21 |     name: "convert_video",
 22 |     description: "Convert a video file to a different format",
 23 |     inputSchema: {
 24 |       type: "object",
 25 |       properties: {
 26 |         inputPath: {
 27 |           type: "string",
 28 |           description: "Path to the input video file"
 29 |         },
 30 |         outputPath: {
 31 |           type: "string",
 32 |           description: "Path for the output video file"
 33 |         },
 34 |         options: {
 35 |           type: "string",
 36 |           description: "Additional FFmpeg options (optional)"
 37 |         }
 38 |       },
 39 |       required: ["inputPath", "outputPath"]
 40 |     }
 41 |   },
 42 |   {
 43 |     name: "extract_audio",
 44 |     description: "Extract audio from a video file",
 45 |     inputSchema: {
 46 |       type: "object",
 47 |       properties: {
 48 |         inputPath: {
 49 |           type: "string",
 50 |           description: "Path to the input video file"
 51 |         },
 52 |         outputPath: {
 53 |           type: "string",
 54 |           description: "Path for the output audio file"
 55 |         },
 56 |         format: {
 57 |           type: "string",
 58 |           description: "Audio format (mp3, aac, etc.)"
 59 |         }
 60 |       },
 61 |       required: ["inputPath", "outputPath", "format"]
 62 |     }
 63 |   },
 64 |   {
 65 |     name: "create_video_from_images",
 66 |     description: "Create a video from a sequence of images",
 67 |     inputSchema: {
 68 |       type: "object",
 69 |       properties: {
 70 |         inputPattern: {
 71 |           type: "string",
 72 |           description: "Pattern for input images (e.g., 'img%03d.jpg' or 'folder/*.png')"
 73 |         },
 74 |         outputPath: {
 75 |           type: "string",
 76 |           description: "Path for the output video file"
 77 |         },
 78 |         framerate: {
 79 |           type: "number",
 80 |           description: "Frames per second (default: 25)"
 81 |         },
 82 |         codec: {
 83 |           type: "string",
 84 |           description: "Video codec to use (default: libx264)"
 85 |         },
 86 |         pixelFormat: {
 87 |           type: "string",
 88 |           description: "Pixel format (default: yuv420p)"
 89 |         },
 90 |         extraOptions: {
 91 |           type: "string",
 92 |           description: "Additional FFmpeg options"
 93 |         }
 94 |       },
 95 |       required: ["inputPattern", "outputPath"]
 96 |     }
 97 |   },
 98 |   {
 99 |     name: "trim_video",
100 |     description: "Trim a video to a specific duration",
101 |     inputSchema: {
102 |       type: "object",
103 |       properties: {
104 |         inputPath: {
105 |           type: "string",
106 |           description: "Path to the input video file"
107 |         },
108 |         outputPath: {
109 |           type: "string",
110 |           description: "Path for the output video file"
111 |         },
112 |         startTime: {
113 |           type: "string",
114 |           description: "Start time (format: HH:MM:SS.mmm or seconds)"
115 |         },
116 |         duration: {
117 |           type: "string",
118 |           description: "Duration (format: HH:MM:SS.mmm or seconds)"
119 |         },
120 |         endTime: {
121 |           type: "string",
122 |           description: "End time (format: HH:MM:SS.mmm or seconds)"
123 |         }
124 |       },
125 |       required: ["inputPath", "outputPath"]
126 |     }
127 |   },
128 |   {
129 |     name: "add_watermark",
130 |     description: "Add a watermark to a video",
131 |     inputSchema: {
132 |       type: "object",
133 |       properties: {
134 |         inputPath: {
135 |           type: "string",
136 |           description: "Path to the input video file"
137 |         },
138 |         watermarkPath: {
139 |           type: "string",
140 |           description: "Path to the watermark image"
141 |         },
142 |         outputPath: {
143 |           type: "string",
144 |           description: "Path for the output video file"
145 |         },
146 |         position: {
147 |           type: "string",
148 |           description: "Position of watermark (topleft, topright, bottomleft, bottomright, center)"
149 |         },
150 |         opacity: {
151 |           type: "number",
152 |           description: "Opacity of watermark (0.0-1.0)"
153 |         }
154 |       },
155 |       required: ["inputPath", "watermarkPath", "outputPath"]
156 |     }
157 |   },
158 |   {
159 |     name: "trim_audio",
160 |     description: "Trim an audio file to a specific duration",
161 |     inputSchema: {
162 |       type: "object",
163 |       properties: {
164 |         inputPath: {
165 |           type: "string",
166 |           description: "Path to the input audio file"
167 |         },
168 |         outputPath: {
169 |           type: "string",
170 |           description: "Path for the output audio file"
171 |         },
172 |         startTime: {
173 |           type: "string",
174 |           description: "Start time (format: HH:MM:SS.mmm or seconds)"
175 |         },
176 |         duration: {
177 |           type: "string",
178 |           description: "Duration (format: HH:MM:SS.mmm or seconds)"
179 |         },
180 |         endTime: {
181 |           type: "string",
182 |           description: "End time (format: HH:MM:SS.mmm or seconds)"
183 |         },
184 |         format: {
185 |           type: "string",
186 |           description: "Audio format for output (mp3, aac, etc.)"
187 |         }
188 |       },
189 |       required: ["inputPath", "outputPath"]
190 |     }
191 |   },
192 |   {
193 |     name: "extract_frames",
194 |     description: "Extract frames from a video as sequential image files",
195 |     inputSchema: {
196 |       type: "object",
197 |       properties: {
198 |         inputPath: {
199 |           type: "string",
200 |           description: "Path to the input video file"
201 |         },
202 |         outputDir: {
203 |           type: "string",
204 |           description: "Directory to save the extracted frames (default: 'output')"
205 |         },
206 |         frameRate: {
207 |           type: "string",
208 |           description: "Frame extraction rate (e.g., '1' for every frame, '0.5' for every 2nd frame, '1/30' for 1 frame per 30 seconds)"
209 |         },
210 |         format: {
211 |           type: "string",
212 |           description: "Output image format (jpg, png, etc., default: jpg)"
213 |         },
214 |         quality: {
215 |           type: "number",
216 |           description: "Image quality for jpg format (1-100, default: 95)"
217 |         },
218 |         startTime: {
219 |           type: "string",
220 |           description: "Start time to begin extraction (format: HH:MM:SS.mmm or seconds)"
221 |         },
222 |         duration: {
223 |           type: "string",
224 |           description: "Duration to extract frames (format: HH:MM:SS.mmm or seconds)"
225 |         }
226 |       },
227 |       required: ["inputPath"]
228 |     }
229 |   }
230 | ];
231 | 
```

--------------------------------------------------------------------------------
/src/tools/handlers.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { validatePath } from "../utils/file.js";
  2 | import { getVideoInfo, runFFmpegCommand } from "../utils/ffmpeg.js";
  3 | import { ensureDirectoryExists } from "../utils/file.js";
  4 | import { join } from "path";
  5 | 
  6 | /**
  7 |  * Handles all FFmpeg tool requests
  8 |  */
  9 | export async function handleToolCall(toolName: string, args: any) {
 10 |   switch (toolName) {
 11 |     case "get_video_info": {
 12 |       const filePath = validatePath(String(args?.filePath), true);
 13 |       const info = await getVideoInfo(filePath);
 14 |       return {
 15 |         content: [{
 16 |           type: "text",
 17 |           text: info
 18 |         }]
 19 |       };
 20 |     }
 21 | 
 22 |     case "convert_video": {
 23 |       const inputPath = validatePath(String(args?.inputPath), true);
 24 |       const outputPath = validatePath(String(args?.outputPath));
 25 |       const options = String(args?.options || "");
 26 |       
 27 |       await ensureDirectoryExists(outputPath);
 28 |       const command = `-i "${inputPath}" ${options} "${outputPath}" -y`;
 29 |       const result = await runFFmpegCommand(command);
 30 |       
 31 |       return {
 32 |         content: [{
 33 |           type: "text",
 34 |           text: `Video conversion completed: ${inputPath} → ${outputPath}\n\n${result}`
 35 |         }]
 36 |       };
 37 |     }
 38 | 
 39 |     case "extract_audio": {
 40 |       const inputPath = validatePath(String(args?.inputPath), true);
 41 |       const outputPath = validatePath(String(args?.outputPath));
 42 |       const format = String(args?.format || "mp3");
 43 |       
 44 |       await ensureDirectoryExists(outputPath);
 45 |       const command = `-i "${inputPath}" -vn -acodec ${format} "${outputPath}" -y`;
 46 |       const result = await runFFmpegCommand(command);
 47 |       
 48 |       return {
 49 |         content: [{
 50 |           type: "text",
 51 |           text: `Audio extraction completed: ${inputPath} → ${outputPath}\n\n${result}`
 52 |         }]
 53 |       };
 54 |     }
 55 | 
 56 |     case "create_video_from_images": {
 57 |       const inputPattern = String(args?.inputPattern);
 58 |       const outputPath = validatePath(String(args?.outputPath));
 59 |       const framerate = Number(args?.framerate || 25);
 60 |       const codec = String(args?.codec || "libx264");
 61 |       const pixelFormat = String(args?.pixelFormat || "yuv420p");
 62 |       const extraOptions = String(args?.extraOptions || "");
 63 |       
 64 |       if (!inputPattern) {
 65 |         throw new Error("Input pattern is required");
 66 |       }
 67 |       
 68 |       await ensureDirectoryExists(outputPath);
 69 |       const command = `-framerate ${framerate} -i "${inputPattern}" -c:v ${codec} -pix_fmt ${pixelFormat} ${extraOptions} "${outputPath}" -y`;
 70 |       const result = await runFFmpegCommand(command);
 71 |       
 72 |       return {
 73 |         content: [{
 74 |           type: "text",
 75 |           text: `Video creation completed: ${inputPattern} → ${outputPath}\n\n${result}`
 76 |         }]
 77 |       };
 78 |     }
 79 | 
 80 |     case "trim_video": {
 81 |       const inputPath = validatePath(String(args?.inputPath), true);
 82 |       const outputPath = validatePath(String(args?.outputPath));
 83 |       const startTime = String(args?.startTime || "0");
 84 |       const duration = String(args?.duration || "");
 85 |       const endTime = String(args?.endTime || "");
 86 |       
 87 |       await ensureDirectoryExists(outputPath);
 88 |       
 89 |       let command = `-i "${inputPath}" -ss ${startTime}`;
 90 |       if (duration) {
 91 |         command += ` -t ${duration}`;
 92 |       } else if (endTime) {
 93 |         command += ` -to ${endTime}`;
 94 |       }
 95 |       command += ` -c copy "${outputPath}" -y`;
 96 |       
 97 |       const result = await runFFmpegCommand(command);
 98 |       
 99 |       return {
100 |         content: [{
101 |           type: "text",
102 |           text: `Video trimming completed: ${inputPath} → ${outputPath}\n\n${result}`
103 |         }]
104 |       };
105 |     }
106 | 
107 |     case "add_watermark": {
108 |       const inputPath = validatePath(String(args?.inputPath), true);
109 |       const watermarkPath = validatePath(String(args?.watermarkPath), true);
110 |       const outputPath = validatePath(String(args?.outputPath));
111 |       const position = String(args?.position || "bottomright");
112 |       const opacity = Number(args?.opacity || 0.5);
113 |       
114 |       await ensureDirectoryExists(outputPath);
115 |       
116 |       // Determine overlay position
117 |       let overlayPosition = "";
118 |       switch (position.toLowerCase()) {
119 |         case "topleft":
120 |           overlayPosition = "10:10";
121 |           break;
122 |         case "topright":
123 |           overlayPosition = "W-w-10:10";
124 |           break;
125 |         case "bottomleft":
126 |           overlayPosition = "10:H-h-10";
127 |           break;
128 |         case "center":
129 |           overlayPosition = "(W-w)/2:(H-h)/2";
130 |           break;
131 |         case "bottomright":
132 |         default:
133 |           overlayPosition = "W-w-10:H-h-10";
134 |           break;
135 |       }
136 |       
137 |       // Improved command with better handling of watermark opacity and format
138 |       const command = `-i "${inputPath}" -i "${watermarkPath}" -filter_complex "[1:v]format=rgba,colorchannelmixer=aa=${opacity}[watermark];[0:v][watermark]overlay=${overlayPosition}:format=auto,format=yuv420p" -codec:a copy "${outputPath}" -y`;
139 |       const result = await runFFmpegCommand(command);
140 |       
141 |       return {
142 |         content: [{
143 |           type: "text",
144 |           text: `Watermark added: ${inputPath} → ${outputPath}\n\n${result}`
145 |         }]
146 |       };
147 |     }
148 | 
149 |     case "trim_audio": {
150 |       const inputPath = validatePath(String(args?.inputPath), true);
151 |       const outputPath = validatePath(String(args?.outputPath));
152 |       const startTime = String(args?.startTime || "0");
153 |       const duration = String(args?.duration || "");
154 |       const endTime = String(args?.endTime || "");
155 |       const format = String(args?.format || "");
156 |       
157 |       await ensureDirectoryExists(outputPath);
158 |       
159 |       // Build the FFmpeg command
160 |       let command = `-i "${inputPath}" -ss ${startTime}`;
161 |       
162 |       // Add duration or end time if provided
163 |       if (duration) {
164 |         command += ` -t ${duration}`;
165 |       } else if (endTime) {
166 |         command += ` -to ${endTime}`;
167 |       }
168 |       
169 |       // Add format if specified, otherwise use copy codec
170 |       if (format) {
171 |         command += ` -acodec ${format}`;
172 |       } else {
173 |         command += ` -acodec copy`;
174 |       }
175 |       
176 |       command += ` "${outputPath}" -y`;
177 |       
178 |       const result = await runFFmpegCommand(command);
179 |       
180 |       return {
181 |         content: [{
182 |           type: "text",
183 |           text: `Audio trimming completed: ${inputPath} → ${outputPath}\n\n${result}`
184 |         }]
185 |       };
186 |     }
187 | 
188 |     case "extract_frames": {
189 |       const inputPath = validatePath(String(args?.inputPath), true);
190 |       const outputDir = String(args?.outputDir || "output");
191 |       const frameRate = String(args?.frameRate || "1");
192 |       const format = String(args?.format || "jpg");
193 |       const quality = Number(args?.quality || 95);
194 |       const startTime = args?.startTime ? String(args?.startTime) : "";
195 |       const duration = args?.duration ? String(args?.duration) : "";
196 |       
197 |       // Create output directory if it doesn't exist
198 |       await ensureDirectoryExists(join(outputDir, "dummy.txt"));
199 |       
200 |       // Build the FFmpeg command
201 |       let command = `-i "${inputPath}"`;
202 |       
203 |       // Add start time if provided
204 |       if (startTime) {
205 |         command += ` -ss ${startTime}`;
206 |       }
207 |       
208 |       // Add duration if provided
209 |       if (duration) {
210 |         command += ` -t ${duration}`;
211 |       }
212 |       
213 |       // Set frame extraction rate
214 |       command += ` -vf "fps=${frameRate}"`;
215 |       
216 |       // Set quality based on format
217 |       if (format.toLowerCase() === "jpg" || format.toLowerCase() === "jpeg") {
218 |         // For JPEG, use a better quality setting (lower values = higher quality in FFmpeg's scale)
219 |         // Convert 1-100 scale to FFmpeg's 1-31 scale (inverted, where 1 is best quality)
220 |         const ffmpegQuality = Math.max(1, Math.min(31, Math.round(31 - ((quality / 100) * 30))));
221 |         command += ` -q:v ${ffmpegQuality}`;
222 |       } else if (format.toLowerCase() === "png") {
223 |         // For PNG, use compression level (0-9, where 0 is no compression)
224 |         const compressionLevel = Math.min(9, Math.max(0, Math.round(9 - ((quality / 100) * 9))));
225 |         command += ` -compression_level ${compressionLevel}`;
226 |       }
227 |       
228 |       // Set output pattern with 5-digit numbering
229 |       const outputPattern = join(outputDir, `%05d.${format}`);
230 |       command += ` "${outputPattern}" -y`;
231 |       
232 |       const result = await runFFmpegCommand(command);
233 |       
234 |       return {
235 |         content: [{
236 |           type: "text",
237 |           text: `Frames extracted from video: ${inputPath} → ${outputDir}/*.${format}\n\n${result}`
238 |         }]
239 |       };
240 |     }
241 | 
242 |     default:
243 |       throw new Error(`Unknown tool: ${toolName}`);
244 |   }
245 | }
246 | 
```