ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s

This commit is contained in:
2026-08-20 13:12:50 +00:00
commit b119135836
10275 changed files with 3284984 additions and 0 deletions
@@ -0,0 +1,555 @@
import json
import os
import subprocess
import time
import traceback
from pathlib import Path
from typing import Any, Literal
from dotenv import load_dotenv
from openai import OpenAI
from pydantic import BaseModel, Field
from pydantic.fields import FieldInfo
from aworld.logs.util import Color
from examples.gaia.mcp_collections.base import ActionArguments, ActionCollection, ActionResponse
class AudioMetadata(BaseModel):
"""Metadata extracted from audio processing."""
file_name: str = Field(description="Original audio file name")
file_size: int = Field(description="File size in bytes")
file_type: str = Field(description="Audio file type/extension")
absolute_path: str = Field(description="Absolute path to the audio file")
duration: float | None = Field(default=None, description="Duration of audio in seconds")
sample_rate: int | None = Field(default=None, description="Audio sample rate in Hz")
channels: int | None = Field(default=None, description="Number of audio channels")
bitrate: int | None = Field(default=None, description="Audio bitrate in kbps")
codec: str | None = Field(default=None, description="Audio codec used")
processing_time: float = Field(description="Time taken to process the audio in seconds")
output_files: list[str] = Field(default_factory=list, description="Paths to generated output files")
transcription: str | None = Field(default=None, description="Transcribed text from audio")
word_count: int | None = Field(default=None, description="Number of words in transcription")
output_format: str = Field(description="Format of the processed output")
class AudioCollection(ActionCollection):
"""MCP service for comprehensive audio processing using ffmpeg.
Supports various audio operations including:
- Audio format conversion
- Audio transcription (speech-to-text)
- Audio metadata extraction
- Audio quality enhancement
- Audio trimming and editing
- Audio analysis and feature extraction
"""
def __init__(self, arguments: ActionArguments) -> None:
super().__init__(arguments)
self._audio_output_dir = self.workspace / "processed_audio"
self._audio_output_dir.mkdir(exist_ok=True)
# Supported audio formats
self.supported_extensions = {
".mp3",
".wav",
".flac",
".aac",
".ogg",
".m4a",
".wma",
".opus",
".aiff",
".au",
".ra",
".amr",
}
self._color_log("Audio Processing Service initialized", Color.green, "debug")
self._color_log(f"Audio output directory: {self._audio_output_dir}", Color.blue, "debug")
# Check ffmpeg availability
self._check_ffmpeg_availability()
def _check_ffmpeg_availability(self) -> bool:
"""Check if ffmpeg is available in the system.
Returns:
bool: True if ffmpeg is available, False otherwise
"""
try:
result = subprocess.run(["ffmpeg", "-version"], capture_output=True, text=True, timeout=10, check=False)
if result.returncode == 0:
self._color_log("FFmpeg is available", Color.green, "debug")
else:
self._color_log("FFmpeg not found in system PATH", Color.red)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError):
self._color_log(
"FFmpeg not available or timeout, Please refer to https://github.com/inclusionAI/AWorld/blob/main/examples/gaia/README.md#system-tools-setup for more details",
Color.red,
)
return False
def _prepare_audio_for_transcription(self, file_path: Path) -> Path:
"""Prepare audio file for transcription by converting to optimal format.
Args:
file_path: Path to the original audio file
Returns:
Path to the prepared audio file (WAV format, 16kHz)
"""
output_path = self._audio_output_dir / f"{file_path.stem}_for_transcription.wav"
# Convert to WAV format with 16kHz sample rate for optimal transcription
cmd = [
"ffmpeg",
"-i",
str(file_path),
"-ar",
"16000", # 16kHz sample rate
"-ac",
"1", # Mono channel
"-c:a",
"pcm_s16le", # 16-bit PCM
"-y",
str(output_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, check=False)
if result.returncode != 0:
raise RuntimeError(f"Audio preparation for transcription failed: {result.stderr}")
return output_path
def _transcribe_with_whisper(self, audio_path: Path) -> dict[str, Any]:
"""Transcribe audio using OpenAI Whisper.
Args:
audio_path: Path to the audio file
Returns:
Dictionary containing transcription results
"""
try:
client: OpenAI = OpenAI(api_key=os.getenv("AUDIO_LLM_API_KEY"), base_url=os.getenv("AUDIO_LLM_BASE_URL"))
# Use the file for transcription
with open(audio_path, "rb") as audio_file:
transcription: str = client.audio.transcriptions.create(
file=audio_file,
model=os.getenv("AUDIO_LLM_MODEL_NAME"),
response_format="text",
)
return {"text": transcription.strip() if transcription else ""}
except Exception as e:
raise RuntimeError(f"Audio transcription failed: {e}: {traceback.format_exc()}") from e
def _get_audio_metadata(self, file_path: Path) -> dict[str, Any]:
"""Extract audio metadata using ffprobe.
Args:
file_path: Path to the audio file
Returns:
Dictionary containing audio metadata
"""
try:
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", str(file_path)]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, check=False)
if result.returncode == 0:
metadata = json.loads(result.stdout)
# Extract relevant audio information
format_info = metadata.get("format", {})
streams = metadata.get("streams", [])
audio_stream = next((s for s in streams if s.get("codec_type") == "audio"), {})
return {
"duration": float(format_info.get("duration", 0)),
"sample_rate": int(audio_stream.get("sample_rate", 0)) if audio_stream.get("sample_rate") else None,
"channels": int(audio_stream.get("channels", 0)) if audio_stream.get("channels") else None,
"bitrate": int(format_info.get("bit_rate", 0)) // 1000 if format_info.get("bit_rate") else None,
"codec": audio_stream.get("codec_name"),
}
else:
self.logger.warning(f"Failed to extract metadata: {result.stderr}")
return {}
except Exception as e:
self.logger.error(f"Error extracting audio metadata: {str(e)}")
return {}
def _trim_audio(self, input_path: Path, start_time: float, duration: float | None = None) -> Path:
"""Trim audio file to specified time range.
Args:
input_path: Path to input audio file
start_time: Start time in seconds
duration: Duration in seconds (if None, trim to end)
Returns:
Path to trimmed audio file
"""
output_path = self._audio_output_dir / f"{input_path.stem}_trimmed{input_path.suffix}"
cmd = ["ffmpeg", "-i", str(input_path), "-ss", str(start_time), "-y"]
if duration is not None:
cmd.extend(["-t", str(duration)])
cmd.extend(["-c", "copy", str(output_path)])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, check=False)
if result.returncode != 0:
raise RuntimeError(f"Audio trimming failed: {result.stderr}")
return output_path
def mcp_transcribe_audio(
self,
file_path: str = Field(description="Path to the audio file to transcribe"),
model_size: Literal["tiny", "base", "small", "medium", "large"] = Field(
default="base",
description="Whisper model size: tiny (fastest), base (balanced), small, medium, large (most accurate)",
),
output_format: Literal["text", "detailed", "segments"] = Field(
default="text",
description="Output format: 'text' (plain text), 'detailed' (with metadata), 'segments' (timestamped)",
),
) -> ActionResponse:
"""Transcribe audio file to text using OpenAI Whisper.
This tool converts speech in audio files to text with high accuracy.
Supports multiple languages and provides various output formats including
timestamped segments for detailed analysis.
Args:
file_path: Path to the audio file to transcribe
model_size: Whisper model size affecting speed vs accuracy trade-off
output_format: Format of transcription output
Returns:
ActionResponse with transcribed text and detailed metadata
"""
try:
# Handle FieldInfo objects
if isinstance(file_path, FieldInfo):
file_path = file_path.default
if isinstance(model_size, FieldInfo):
model_size = model_size.default
if isinstance(output_format, FieldInfo):
output_format = output_format.default
start_time = time.time()
# Validate input file
file_path: Path = self._validate_file_path(file_path)
self._color_log(f"Transcribing audio: {file_path.name}", Color.cyan)
# Get original metadata
original_metadata = self._get_audio_metadata(file_path)
# Prepare audio for transcription
prepared_audio = self._prepare_audio_for_transcription(file_path)
# Perform transcription
transcription_result = self._transcribe_with_whisper(prepared_audio)
processing_time = time.time() - start_time
# Prepare file statistics
file_stats = file_path.stat()
# Count words in transcription
word_count = len(transcription_result["text"].split()) if transcription_result["text"] else 0
# Create metadata object
audio_metadata = AudioMetadata(
file_name=file_path.name,
file_size=file_stats.st_size,
file_type=file_path.suffix.lower(),
absolute_path=str(file_path.absolute()),
duration=original_metadata.get("duration"),
sample_rate=original_metadata.get("sample_rate"),
channels=original_metadata.get("channels"),
bitrate=original_metadata.get("bitrate"),
codec=original_metadata.get("codec"),
processing_time=processing_time,
output_files=[str(prepared_audio)],
transcription=transcription_result["text"],
word_count=word_count,
output_format=f"transcription_{output_format}",
)
# Format output based on requested format
if output_format == "text":
result_message = transcription_result["text"]
elif output_format == "detailed":
confidence_str = (
f"{transcription_result['confidence']:.2f}" if transcription_result["confidence"] else "N/A"
)
result_message = (
f"Transcription Results for {file_path.name}:\n\n"
f"**Text:** {transcription_result['text']}\n\n"
f"**Confidence:** {confidence_str}\n"
f"**Word Count:** {word_count}\n"
f"**Duration:** {original_metadata.get('duration', 0):.2f} seconds\n"
f"**Model:** {model_size}\n"
f"**Processing Time:** {processing_time:.2f} seconds"
)
elif output_format == "segments":
segments_text = "\n".join(
[
f"[{seg.get('start', 0):.2f}s - {seg.get('end', 0):.2f}s]: {seg.get('text', '').strip()}"
for seg in transcription_result.get("segments", [])
]
)
result_message = (
f"Timestamped Transcription for {file_path.name}:\n\n"
f"{segments_text}\n\n"
f"**Full Text:** {transcription_result['text']}"
)
else:
result_message = transcription_result["text"]
# Clean up temporary file
try:
prepared_audio.unlink()
except Exception:
pass # Ignore cleanup errors
self._color_log(f"Transcription completed: {word_count} words, {processing_time:.2f}s", Color.green)
return ActionResponse(success=True, message=result_message, metadata=audio_metadata.model_dump())
except Exception as e:
self.logger.error(f"Audio transcription failed: {str(e)}: {traceback.format_exc()}")
return ActionResponse(
success=False,
message=f"Audio transcription failed: {str(e)}",
metadata={"error_type": "transcription_error"},
)
def mcp_extract_audio_metadata(
self,
file_path: str = Field(description="Path to the audio file to analyze"),
) -> ActionResponse:
"""Extract comprehensive metadata from audio files.
This tool analyzes audio files and extracts detailed metadata including
duration, sample rate, channels, bitrate, codec, and other technical information.
Args:
file_path: Path to the audio file to analyze
Returns:
ActionResponse with detailed audio metadata
"""
try:
if isinstance(file_path, FieldInfo):
file_path = file_path.default
start_time = time.time()
# Validate input file
file_path: Path = self._validate_file_path(file_path)
self._color_log(f"Extracting metadata from: {file_path.name}", Color.cyan)
# Extract metadata
metadata = self._get_audio_metadata(file_path)
processing_time = time.time() - start_time
# Prepare file statistics
file_stats = file_path.stat()
# Create metadata object
audio_metadata = AudioMetadata(
file_name=file_path.name,
file_size=file_stats.st_size,
file_type=file_path.suffix.lower(),
absolute_path=str(file_path.absolute()),
duration=metadata.get("duration"),
sample_rate=metadata.get("sample_rate"),
channels=metadata.get("channels"),
bitrate=metadata.get("bitrate"),
codec=metadata.get("codec"),
processing_time=processing_time,
output_files=[],
output_format="metadata",
)
# Format metadata for LLM consumption
result_message = (
f"Audio Metadata for {file_path.name}:\n"
f"Duration: {metadata.get('duration', 'Unknown'):.2f} seconds\n"
f"Sample Rate: {metadata.get('sample_rate', 'Unknown')} Hz\n"
f"Channels: {metadata.get('channels', 'Unknown')}\n"
f"Bitrate: {metadata.get('bitrate', 'Unknown')} kbps\n"
f"Codec: {metadata.get('codec', 'Unknown')}\n"
f"File Size: {file_stats.st_size / 1024 / 1024:.2f} MB\n"
f"Format: {file_path.suffix.upper()}"
)
self._color_log(f"Metadata extraction completed in {processing_time:.2f}s", Color.green)
return ActionResponse(success=True, message=result_message, metadata=audio_metadata.model_dump())
except Exception as e:
self.logger.error(f"Metadata extraction failed: {str(e)}: {traceback.format_exc()}")
return ActionResponse(
success=False,
message=f"Metadata extraction failed: {str(e)}",
metadata={"error_type": "metadata_error"},
)
def mcp_trim_audio(
self,
file_path: str = Field(description="Path to the audio file to trim"),
start_time: float = Field(description="Start time in seconds"),
duration: float | None = Field(default=None, description="Duration in seconds (if None, trim to end)"),
) -> ActionResponse:
"""Trim audio file to specified time range.
This tool cuts audio files to extract specific segments based on start time
and duration. Useful for creating clips or removing unwanted sections.
Args:
file_path: Path to the source audio file
start_time: Start time in seconds for trimming
duration: Duration of the trimmed segment (optional)
Returns:
ActionResponse with trimmed audio file path and metadata
"""
try:
if isinstance(file_path, FieldInfo):
file_path = file_path.default
if isinstance(start_time, FieldInfo):
start_time = start_time.default
if isinstance(duration, FieldInfo):
duration = duration.default
process_start = time.time()
# Validate input file
file_path: Path = self._validate_file_path(file_path)
self._color_log(f"Trimming audio: {file_path.name}", Color.cyan)
# Get original metadata
original_metadata = self._get_audio_metadata(file_path)
# Validate time parameters
if start_time < 0:
raise ValueError("Start time cannot be negative")
if duration is not None and duration <= 0:
raise ValueError("Duration must be positive")
if original_metadata.get("duration") and start_time >= original_metadata["duration"]:
raise ValueError("Start time exceeds audio duration")
# Trim audio
output_path = self._trim_audio(file_path, start_time, duration)
# Get trimmed file metadata
trimmed_metadata = self._get_audio_metadata(output_path)
processing_time = time.time() - process_start
# Prepare file statistics
file_stats = file_path.stat()
# Create metadata object
audio_metadata = AudioMetadata(
file_name=file_path.name,
file_size=file_stats.st_size,
file_type=file_path.suffix.lower(),
absolute_path=str(file_path.absolute()),
duration=trimmed_metadata.get("duration"),
sample_rate=trimmed_metadata.get("sample_rate"),
channels=trimmed_metadata.get("channels"),
bitrate=trimmed_metadata.get("bitrate"),
codec=trimmed_metadata.get("codec"),
processing_time=processing_time,
output_files=[str(output_path)],
output_format="trimmed_audio",
)
end_time = start_time + (duration or (original_metadata.get("duration", 0) - start_time))
result_message = (
f"Successfully trimmed {file_path.name}\n"
f"Original duration: {original_metadata.get('duration', 0):.2f} seconds\n"
f"Trimmed segment: {start_time:.2f}s - {end_time:.2f}s\n"
f"New duration: {trimmed_metadata.get('duration', 0):.2f} seconds\n"
f"Output file: {output_path.name}"
)
self._color_log(f"Audio trimming completed in {processing_time:.2f}s", Color.green)
return ActionResponse(success=True, message=result_message, metadata=audio_metadata.model_dump())
except Exception as e:
self.logger.error(f"Audio trimming failed: {str(e)}: {traceback.format_exc()}")
return ActionResponse(
success=False, message=f"Audio trimming failed: {str(e)}", metadata={"error_type": "trimming_error"}
)
def mcp_list_supported_formats(self) -> ActionResponse:
"""List all supported audio formats for processing.
Returns:
ActionResponse with list of supported audio formats and their descriptions
"""
supported_formats = {
"MP3": "MPEG Audio Layer III (.mp3) - Most common compressed format",
"WAV": "Waveform Audio File Format (.wav) - Uncompressed, high quality",
"FLAC": "Free Lossless Audio Codec (.flac) - Lossless compression",
"AAC": "Advanced Audio Coding (.aac) - Efficient compression",
"OGG": "Ogg Vorbis (.ogg) - Open source compressed format",
"M4A": "MPEG-4 Audio (.m4a) - Apple's preferred format",
"WMA": "Windows Media Audio (.wma) - Microsoft format",
"OPUS": "Opus Audio (.opus) - Modern, efficient codec",
"AIFF": "Audio Interchange File Format (.aiff) - Apple's uncompressed format",
"AU": "Sun Audio (.au) - Unix audio format",
"RA": "RealAudio (.ra) - Streaming audio format",
"AMR": "Adaptive Multi-Rate (.amr) - Mobile audio format",
}
format_list = "\n".join(
[f"**{format_name}**: {description}" for format_name, description in supported_formats.items()]
)
return ActionResponse(
success=True,
message=f"Supported audio formats:\n\n{format_list}",
metadata={
"supported_formats": list(supported_formats.keys()),
"total_formats": len(supported_formats),
"ffmpeg_available": self._check_ffmpeg_availability(),
},
)
# Example usage and entry point
if __name__ == "__main__":
load_dotenv()
# Default arguments for testing
args = ActionArguments(
name="audio_processing_service",
transport="stdio",
workspace=os.getenv("AWORLD_WORKSPACE", "~"),
)
# Initialize and run the audio processing service
try:
service = AudioCollection(args)
service.run()
print("Audio processing service started")
except Exception as e:
print(f"An error occurred: {e}: {traceback.format_exc()}")
@@ -0,0 +1,484 @@
import base64
import os
import time
import traceback
from io import BytesIO
from pathlib import Path
from typing import Any
import pytesseract
from dotenv import load_dotenv
from PIL import Image, ImageEnhance, ImageFilter
from pydantic import BaseModel, Field
from pydantic.fields import FieldInfo
from aworld.config.conf import AgentConfig
from aworld.logs.util import Color
from aworld.models.llm import call_llm_model, get_llm_model
from aworld.models.model_response import ModelResponse
from examples.gaia.mcp_collections.base import ActionArguments, ActionCollection, ActionResponse
class ImageMetadata(BaseModel):
"""Metadata extracted from image processing."""
file_name: str = Field(description="Original image file name")
file_size: int = Field(description="File size in bytes")
file_type: str = Field(description="Image file type/extension")
absolute_path: str = Field(description="Absolute path to the image file")
width: int | None = Field(default=None, description="Image width in pixels")
height: int | None = Field(default=None, description="Image height in pixels")
mode: str | None = Field(default=None, description="Image color mode (RGB, RGBA, L, etc.)")
format: str | None = Field(default=None, description="Image format (JPEG, PNG, etc.)")
has_transparency: bool = Field(default=False, description="Whether image has transparency")
processing_time: float = Field(description="Time taken to process the image in seconds", exclude=True)
output_files: list[str] = Field(default_factory=list, description="Paths to generated output files")
extracted_text: str | None = Field(default=None, description="Text extracted via OCR")
analysis_result: str | None = Field(default=None, description="AI analysis result")
compression_ratio: float | None = Field(default=None, description="Compression ratio if optimized")
output_format: str = Field(description="Format of the processed output")
class ImageCollection(ActionCollection):
"""MCP service for comprehensive image processing and analysis.
Supports various image operations including:
- Metadata extraction
- OCR (Optical Character Recognition)
- AI-powered image analysis and reasoning
"""
def __init__(self, arguments: ActionArguments) -> None:
super().__init__(arguments)
self._image_output_dir = self.workspace / "processed_images"
self._image_output_dir.mkdir(exist_ok=True)
# Supported image formats
self.supported_extensions = {
".jpg",
".jpeg",
".png",
".gif",
".webp",
".bmp",
".tiff",
".tif",
".ico",
".svg",
}
self._llm_config = AgentConfig(
llm_provider="openai",
llm_model_name=os.getenv("IMAGE_LLM_MODEL_NAME", "gpt-4o"),
llm_api_key=os.getenv("IMAGE_LLM_API_KEY"),
llm_base_url=os.getenv("IMAGE_LLM_BASE_URL"),
)
self._color_log("Image Processing Service initialized", Color.green, "debug")
self._color_log(f"Image output directory: {self._image_output_dir}", Color.blue, "debug")
def _load_image(self, file_path: Path) -> Image.Image:
"""Load image from file path.
Args:
file_path: Path to the image file
Returns:
PIL Image object
"""
try:
return Image.open(file_path)
except Exception as e:
raise RuntimeError(f"Failed to load image {file_path}: {str(e)}") from e
def _get_image_metadata(self, image: Image.Image, file_path: Path) -> dict[str, Any]:
"""Extract metadata from PIL Image object.
Args:
image: PIL Image object
file_path: Path to the original file
Returns:
Dictionary containing image metadata
"""
return {
"width": image.width,
"height": image.height,
"mode": image.mode,
"format": image.format or file_path.suffix.upper().lstrip("."),
"has_transparency": image.mode in ("RGBA", "LA") or "transparency" in image.info,
}
def _optimize_image(self, image: Image.Image, max_size: tuple[int, int] | None = None) -> Image.Image:
"""Optimize image for size and quality.
Args:
image: PIL Image object
max_size: Maximum dimensions (width, height)
Returns:
Optimized PIL Image object
"""
optimized = image.copy()
# Resize if max_size specified
if max_size:
optimized.thumbnail(max_size, Image.Resampling.LANCZOS)
return optimized
def _perform_ocr(self, image: Image.Image) -> str:
"""Perform OCR on image to extract text.
Args:
image: PIL Image object
Returns:
Extracted text string
"""
try:
return pytesseract.image_to_string(image).strip()
except ImportError:
return "OCR not available - pytesseract not installed"
except Exception as e:
return f"OCR failed: {str(e)}"
def _analyze_with_ai(self, image_base64: str, task: str) -> str:
"""Analyze image using AI model.
Args:
image_base64: Base64 encoded image
task: Analysis task description
Returns:
AI analysis result
"""
try:
messages = [
{
"role": "user",
"content": [
{"role": "text", "content": task},
{"type": "image_url", "image_url": {"url": image_base64}},
],
},
]
response: ModelResponse = call_llm_model(
llm_model=get_llm_model(conf=self._llm_config),
messages=messages,
temperature=float(os.getenv("LLM_TEMPERATURE", "1.0")),
)
self._color_log(f"{response.content=}", Color.green)
return response.content
except Exception as e:
return f"AI analysis failed: {str(e)}"
def _image_to_base64(self, image: Image.Image, output_format: str = "JPEG") -> str:
"""Convert PIL Image to base64 string.
Args:
image: PIL Image object
output_format: Output format (JPEG, PNG, etc.)
Returns:
Base64 encoded image string
"""
buffer = BytesIO()
# Convert RGBA or P to RGB for JPEG
if output_format.upper() == "JPEG":
if image.mode in ("RGBA", "LA"):
background = Image.new("RGB", image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1] if image.mode == "RGBA" else None)
image = background
elif image.mode == "P":
image = image.convert("RGB")
image.save(buffer, format=output_format, quality=85 if output_format.upper() == "JPEG" else None)
mime_type = f"image/{output_format.lower()}"
img_base64 = base64.b64encode(buffer.getvalue()).decode()
return f"data:{mime_type};base64,{img_base64}"
def mcp_extract_text_ocr(
self,
file_path: str = Field(description="Path to the image file for OCR"),
language: str = Field(default="eng", description="OCR language code (e.g., 'eng', 'spa', 'fra')"),
preprocess: bool = Field(default=True, description="Whether to preprocess image for better OCR"),
) -> ActionResponse:
"""Extract text from images using Optical Character Recognition (OCR).
This tool uses Tesseract OCR to extract text content from images,
with optional preprocessing to improve recognition accuracy.
Args:
file_path: Path to the image file
language: OCR language for better recognition
preprocess: Whether to enhance image for OCR
Returns:
ActionResponse with extracted text and metadata
"""
try:
# Handle FieldInfo objects
if isinstance(file_path, FieldInfo):
file_path = file_path.default
if isinstance(language, FieldInfo):
language = language.default
if isinstance(preprocess, FieldInfo):
preprocess = preprocess.default
start_time = time.time()
# Validate input file
file_path: Path = self._validate_file_path(file_path)
self._color_log(f"Performing OCR on: {file_path.name}", Color.cyan)
# Load image
image = self._load_image(file_path)
original_metadata = self._get_image_metadata(image, file_path)
# Preprocess image for better OCR if requested
if preprocess:
# Convert to grayscale
if image.mode != "L":
image = image.convert("L")
# Enhance contrast
enhancer = ImageEnhance.Contrast(image)
image = enhancer.enhance(2.0)
# Apply slight sharpening
image = image.filter(ImageFilter.SHARPEN)
# Perform OCR
extracted_text = self._perform_ocr(image)
processing_time = time.time() - start_time
# Count words and characters
word_count = len(extracted_text.split()) if extracted_text else 0
char_count = len(extracted_text) if extracted_text else 0
# Create metadata object
image_metadata = ImageMetadata(
file_name=file_path.name,
file_size=file_path.stat().st_size,
file_type=file_path.suffix.lower(),
absolute_path=str(file_path.absolute()),
width=original_metadata["width"],
height=original_metadata["height"],
mode=original_metadata["mode"],
format=original_metadata["format"],
has_transparency=original_metadata["has_transparency"],
processing_time=processing_time,
output_files=[],
extracted_text=extracted_text,
output_format="ocr_text",
)
if extracted_text:
result_message = (
f"OCR Results for {file_path.name}:\n\n"
f"**Extracted Text:**\n{extracted_text}\n\n"
f"**Statistics:**\n"
f"- Words: {word_count}\n"
f"- Characters: {char_count}\n"
f"- Language: {language}\n"
f"- Processing time: {processing_time:.2f}s"
)
else:
result_message = (
f"No text detected in {file_path.name}."
" The image may not contain readable text or OCR preprocessing may be needed."
)
self._color_log(f"OCR completed: {word_count} words extracted in {processing_time:.2f}s", Color.green)
return ActionResponse(success=True, message=result_message, metadata=image_metadata.model_dump())
except Exception as e:
self.logger.error(f"OCR failed: {str(e)}: {traceback.format_exc()}")
return ActionResponse(
success=False,
message=f"OCR failed: {str(e)}",
metadata={"error_type": "ocr_error"},
)
def mcp_analyze_image_ai(
self,
file_path: str = Field(description="Path to the image file for AI analysis"),
task: str = Field(
default="Describe what you see in this image",
description="Specific analysis task or question about the image",
),
) -> ActionResponse:
"""Analyze image content using AI vision models.
This tool uses advanced AI models to analyze and describe image content,
answer questions about images, or perform specific visual reasoning tasks.
Args:
file_path: Path to the image file
task: Specific analysis task or question
Returns:
ActionResponse with AI analysis results and metadata
"""
try:
# Handle FieldInfo objects
if isinstance(file_path, FieldInfo):
file_path = file_path.default
if isinstance(task, FieldInfo):
task = task.default
start_time = time.time()
# Validate input file
file_path: Path = self._validate_file_path(file_path)
self._color_log(f"Analyzing image with AI: {file_path.name}", Color.cyan)
# Load image
image = self._load_image(file_path)
original_metadata = self._get_image_metadata(image, file_path)
# Convert to base64 for AI analysis
image_base64 = self._image_to_base64(image, "JPEG")
# Perform AI analysis
analysis_result = self._analyze_with_ai(image_base64, task)
processing_time = time.time() - start_time
# Create metadata object
metadata_dict = {
"file_name": file_path.name,
"file_size": file_path.stat().st_size,
"file_type": file_path.suffix.lower(),
"absolute_path": str(file_path.absolute()),
"width": original_metadata["width"],
"height": original_metadata["height"],
"mode": original_metadata["mode"],
"format": original_metadata["format"],
"has_transparency": original_metadata["has_transparency"],
"processing_time": processing_time,
"output_files": [],
"analysis_result": analysis_result,
"output_format": "ai_analysis",
}
image_metadata = ImageMetadata(**metadata_dict)
result_message = (
f"AI Analysis Results for {file_path.name}:\n\n"
f"**Task:** {task}\n\n"
f"**Analysis:**\n{analysis_result}\n\n"
f"**Image Info:**\n"
f"- Dimensions: {original_metadata['width']}x{original_metadata['height']}\n"
f"- Format: {original_metadata['format']}\n"
f"- Processing time: {processing_time:.2f}s"
)
self._color_log(f"AI analysis completed in {processing_time:.2f}s", Color.green)
return ActionResponse(success=True, message=result_message, metadata=image_metadata.model_dump())
except Exception as e:
self.logger.error(f"AI image analysis failed: {str(e)}: {traceback.format_exc()}")
return ActionResponse(
success=False,
message=f"AI image analysis failed: {str(e)}",
metadata={"error_type": "ai_analysis_error"},
)
def mcp_get_image_metadata(
self,
file_path: str = Field(description="Path to the image file to analyze"),
) -> ActionResponse:
"""Extract comprehensive metadata from image files.
This tool analyzes image files and extracts detailed metadata including
dimensions, format, color mode, file size, and other technical information.
Args:
file_path: Path to the image file to analyze
Returns:
ActionResponse with detailed image metadata
"""
try:
if isinstance(file_path, FieldInfo):
file_path = file_path.default
start_time = time.time()
# Validate input file
file_path: Path = self._validate_file_path(file_path)
self._color_log(f"Extracting metadata from: {file_path.name}", Color.cyan)
# Load image
image = self._load_image(file_path)
metadata = self._get_image_metadata(image, file_path)
processing_time = time.time() - start_time
# Get file statistics
file_stats = file_path.stat()
# Create metadata object
image_metadata = ImageMetadata(
file_name=file_path.name,
file_size=file_stats.st_size,
file_type=file_path.suffix.lower(),
absolute_path=str(file_path.absolute()),
width=metadata["width"],
height=metadata["height"],
mode=metadata["mode"],
format=metadata["format"],
has_transparency=metadata["has_transparency"],
processing_time=processing_time,
output_files=[],
output_format="metadata",
)
# Calculate additional info
aspect_ratio = metadata["width"] / metadata["height"] if metadata["height"] > 0 else 0
megapixels = (metadata["width"] * metadata["height"]) / 1_000_000
result_message = (
f"Image Metadata for {file_path.name}:\n"
f"Dimensions: {metadata['width']}x{metadata['height']} pixels\n"
f"Aspect Ratio: {aspect_ratio:.2f}:1\n"
f"Megapixels: {megapixels:.2f} MP\n"
f"Color Mode: {metadata['mode']}\n"
f"Format: {metadata['format']}\n"
f"Has Transparency: {metadata['has_transparency']}\n"
f"File Size: {file_stats.st_size / 1024 / 1024:.2f} MB\n"
f"File Type: {file_path.suffix.upper()}"
)
self._color_log(f"Metadata extraction completed in {processing_time:.2f}s", Color.green)
return ActionResponse(success=True, message=result_message, metadata=image_metadata.model_dump())
except Exception as e:
self.logger.error(f"Metadata extraction failed: {str(e)}: {traceback.format_exc()}")
return ActionResponse(
success=False,
message=f"Metadata extraction failed: {str(e)}",
metadata={"error_type": "metadata_error"},
)
if __name__ == "__main__":
load_dotenv()
# Default arguments for testing
args = ActionArguments(
name="image_analysis_service",
transport="stdio",
workspace=os.getenv("AWORLD_WORKSPACE", "~"),
)
# Initialize and run the image analysis service
try:
service = ImageCollection(args)
service.run()
except Exception as e:
print(f"An error occurred: {str(e)}")
@@ -0,0 +1,992 @@
"""
Video MCP Service
This module provides MCP service functionality for video operations including:
- Video content analysis with AI-powered insights
- Video summarization and key point extraction
- Keyframe extraction with scene detection
- Subtitle extraction from video content
It handles various video formats with proper validation, error handling,
and progress tracking while providing LLM-friendly formatted results.
"""
import base64
import os
import time
import traceback
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import cv2
import numpy as np
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from aworld.config.conf import AgentConfig
from aworld.logs.util import Color
from aworld.models.llm import call_llm_model, get_llm_model
from aworld.models.model_response import ModelResponse
from examples.gaia.mcp_collections.base import (
ActionArguments,
ActionCollection,
ActionResponse,
)
from ..utils import get_file_from_source
class VideoAnalysisResult(BaseModel):
"""Video analysis result model with structured data"""
video_source: str
analysis_result: str
frame_count: int
duration_analyzed: float
success: bool
error: str | None = None
class VideoSummaryResult(BaseModel):
"""Video summary result model with structured data"""
video_source: str
summary: str
frame_count: int
duration_analyzed: float
success: bool
error: str | None = None
class KeyframeResult(BaseModel):
"""Keyframe extraction result model with file information"""
frame_paths: list[str]
frame_timestamps: list[float]
output_directory: str
frame_count: int
target_time: float
window_size: float
success: bool
error: str | None = None
class VideoMetadata(BaseModel):
"""Metadata for video operation results"""
operation: str
video_source: str | None = None
sample_rate: int | None = None
start_time: float | None = None
end_time: float | None = None
target_time: float | None = None
window_size: float | None = None
output_directory: str | None = None
frame_count: int | None = None
execution_time: float | None = None
error_type: str | None = None
class VideoCollection(ActionCollection):
"""MCP service for video operations with AI-powered analysis.
Provides video processing capabilities including:
- AI-powered video content analysis
- Video summarization with key insights
- Keyframe extraction with scene detection
- Subtitle extraction from video content
- LLM-friendly result formatting
- Error handling and logging
"""
def __init__(self, arguments: ActionArguments) -> None:
super().__init__(arguments)
# Initialize supported video extensions
self.supported_extensions = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv"}
# Video analysis prompts
self.video_analyze_prompt = (
"Input is a sequence of video frames. Given user's task: {task}. "
"analyze the video content following these steps:\n"
"1. Temporal sequence understanding\n"
"2. Motion and action analysis\n"
"3. Scene context interpretation\n"
"4. Object and person tracking\n"
)
self.video_summarize_prompt = (
"Input is a sequence of video frames. "
"Summarize the main content of the video. "
"Include key points, main topics, and important visual elements. "
)
self._color_log("Video service initialized", Color.green, "debug")
def _get_video_frames(
self,
video_source: str,
sample_rate: int = 2,
start_time: float = 0,
end_time: float | None = None,
) -> list[dict[str, any]]:
"""Extract frames from video with given sample rate.
Args:
video_source: Path or URL to the video file
sample_rate: Number of frames to sample per second
start_time: Start time of the video segment in seconds
end_time: End time of the video segment in seconds
Returns:
List of dictionaries containing frame data and timestamp
Raises:
ValueError: When video file cannot be opened or is not valid
"""
try:
# Get file with validation (only video files allowed)
file_path, _, _ = get_file_from_source(
video_source,
max_size_mb=2500.0, # 2500MB limit for videos
)
# Open video file
video = cv2.VideoCapture(file_path) # pylint: disable=E1101
if not video.isOpened():
raise ValueError(f"Could not open video file: {file_path}")
fps = video.get(cv2.CAP_PROP_FPS) # pylint: disable=E1101
frame_count = int(video.get(cv2.CAP_PROP_FRAME_COUNT)) # pylint: disable=E1101
video_duration = frame_count / fps
if end_time is None:
end_time = video_duration
if start_time > end_time:
raise ValueError("Start time cannot be greater than end time.")
if start_time < 0:
start_time = 0
if end_time > video_duration:
end_time = video_duration
start_frame = int(start_time * fps)
end_frame = int(end_time * fps)
all_frames = []
frames = []
# Calculate frame interval based on sample rate
frame_interval = max(1, int(fps / sample_rate))
# Set the video capture to the start frame
video.set(cv2.CAP_PROP_POS_FRAMES, start_frame) # pylint: disable=E1101
for i in range(start_frame, end_frame):
ret, frame = video.read()
if not ret:
break
# Convert frame to JPEG format
_, buffer = cv2.imencode(".jpg", frame) # pylint: disable=E1101
frame_data = base64.b64encode(buffer).decode("utf-8")
# Add data URL prefix for JPEG image
frame_data = f"data:image/jpeg;base64,{frame_data}"
all_frames.append({"data": frame_data, "time": i / fps})
for i in range(0, len(all_frames), frame_interval):
frames.append(all_frames[i])
video.release()
# Clean up temporary file if it was created for a URL
if (
file_path != str(Path(video_source).resolve())
and Path(file_path).exists()
):
Path(file_path).unlink()
if not frames:
raise ValueError(
f"Could not extract any frames from video: {video_source}"
)
return frames
except Exception as e:
self._color_log(
f"Error extracting frames from {video_source}: {str(e)}", Color.red
)
raise
def _create_video_content(
self, prompt: str, video_frames: list[dict[str, any]]
) -> list[dict[str, any]]:
"""Create uniform video format for querying LLM."""
content = [{"type": "text", "text": prompt}]
content.extend(
[
{"type": "image_url", "image_url": {"url": frame["data"]}}
for frame in video_frames
]
)
return content
def _format_analysis_output(
self, result: VideoAnalysisResult, format_type: str = "markdown"
) -> str:
"""Format video analysis results for LLM consumption.
Args:
result: Video analysis result
format_type: Output format ('markdown', 'json', 'text')
Returns:
Formatted string suitable for LLM consumption
"""
if not result.success:
return f"Failed to analyze video: {result.error}"
if format_type == "json":
return result.model_dump_json(indent=2)
elif format_type == "text":
output_parts = [
"Video Analysis Results",
f"Source: {result.video_source}",
f"Frames Analyzed: {result.frame_count}",
f"Duration: {result.duration_analyzed:.2f} seconds",
"",
"Analysis:",
result.analysis_result,
]
return "\n".join(output_parts)
else: # markdown (default)
output_parts = [
"# Video Analysis Results ✅",
"",
"## Video Information",
f"**Source:** `{result.video_source}`",
f"**Frames Analyzed:** {result.frame_count}",
f"**Duration:** {result.duration_analyzed:.2f} seconds",
"",
"## Analysis Results",
result.analysis_result,
]
return "\n".join(output_parts)
def _format_summary_output(
self, result: VideoSummaryResult, format_type: str = "markdown"
) -> str:
"""Format video summary results for LLM consumption.
Args:
result: Video summary result
format_type: Output format ('markdown', 'json', 'text')
Returns:
Formatted string suitable for LLM consumption
"""
if not result.success:
return f"Failed to summarize video: {result.error}"
if format_type == "json":
return result.model_dump_json(indent=2)
elif format_type == "text":
output_parts = [
"Video Summary",
f"Source: {result.video_source}",
f"Frames Analyzed: {result.frame_count}",
f"Duration: {result.duration_analyzed:.2f} seconds",
"",
"Summary:",
result.summary,
]
return "\n".join(output_parts)
else: # markdown (default)
output_parts = [
"# Video Summary ✅",
"",
"## Video Information",
f"**Source:** `{result.video_source}`",
f"**Frames Analyzed:** {result.frame_count}",
f"**Duration:** {result.duration_analyzed:.2f} seconds",
"",
"## Summary",
result.summary,
]
return "\n".join(output_parts)
def _format_keyframe_output(
self, result: KeyframeResult, format_type: str = "markdown"
) -> str:
"""Format keyframe extraction results for LLM consumption.
Args:
result: Keyframe extraction result
format_type: Output format ('markdown', 'json', 'text')
Returns:
Formatted string suitable for LLM consumption
"""
if not result.success:
return f"Failed to extract keyframes: {result.error}"
if format_type == "json":
return result.model_dump_json(indent=2)
elif format_type == "text":
output_parts = [
"Keyframe Extraction Results",
f"Target Time: {result.target_time}s",
f"Window Size: {result.window_size}s",
f"Frames Extracted: {result.frame_count}",
f"Output Directory: {result.output_directory}",
"",
"Frame Files:",
]
for i, (path, timestamp) in enumerate(
zip(result.frame_paths, result.frame_timestamps), 1
):
output_parts.append(f"{i}. {path} (at {timestamp:.2f}s)")
return "\n".join(output_parts)
else: # markdown (default)
output_parts = [
"# Keyframe Extraction Results ✅",
"",
"## Extraction Parameters",
f"**Target Time:** {result.target_time}s",
f"**Window Size:** {result.window_size}s",
f"**Frames Extracted:** {result.frame_count}",
f"**Output Directory:** `{result.output_directory}`",
"",
"## Extracted Frames",
]
for i, (path, timestamp) in enumerate(
zip(result.frame_paths, result.frame_timestamps), 1
):
output_parts.append(f"{i}. `{path}` (at {timestamp:.2f}s)")
return "\n".join(output_parts)
def _analyze_frame_chunk(
self, chunk_data: tuple[int, list, str]
) -> tuple[int, str]:
"""Analyze a chunk of video frames using LLM.
Args:
chunk_data: Tuple containing (chunk_index, frames, question)
Returns:
Tuple of (chunk_index, analysis_result)
"""
chunk_index, frames, question = chunk_data
try:
content = self._create_video_content(
self.video_analyze_prompt.format(task=question), frames
)
inputs = [{"role": "user", "content": content}]
response: ModelResponse = call_llm_model(
get_llm_model(
conf=AgentConfig(
llm_provider="openai",
llm_model_name=os.getenv("VIDEO_LLM_MODEL_NAME"),
llm_api_key=os.getenv("VIDEO_LLM_API_KEY"),
llm_base_url=os.getenv("VIDEO_LLM_BASE_URL"),
)
),
inputs,
temperature=float(os.getenv("VIDEO_LLM_TEMPERATURE", "1.0")),
)
analysis_result = response.content
self._color_log(
f"✅ Completed analysis for chunk {chunk_index + 1}", Color.green
)
except Exception as e:
self._color_log(
f"❌ LLM analysis error for chunk {chunk_index + 1}: {str(e)}",
Color.yellow,
)
analysis_result = (
f"Analysis failed for video segment {chunk_index + 1}: {str(e)}"
)
return chunk_index, analysis_result
async def mcp_analyze_video(
self,
video_url: str = Field(description="Path or URL to the video file to analyze"),
question: str = Field(description="Question or task for video analysis"),
sample_rate: float = Field(
default=1.0, description="Frame sampling rate (frames per second)"
),
start_time: float = Field(default=0.0, description="Start time in seconds"),
end_time: float | None = Field(
default=None, description="End time in seconds (None for full video)"
),
output_format: str = Field(
default="markdown",
description="Output format: 'markdown', 'json', or 'text'",
),
max_workers: int = Field(
default=4, description="Maximum number of parallel workers for analysis"
),
) -> ActionResponse:
"""Analyze video content using AI with parallel processing.
This tool provides comprehensive video analysis capabilities including:
- Content understanding and description
- Object and scene detection
- Action and movement analysis
- Temporal event tracking
- Question-answering about video content
- Parallel processing for faster analysis
Args:
video_url: Path or URL to the video file
question: Specific question or analysis task
sample_rate: Frame sampling rate for analysis
start_time: Start time of the video segment in seconds
end_time: End time of the video segment in seconds
output_format: Format for the response output
max_workers: Maximum number of parallel workers
Returns:
ActionResponse with video analysis results and metadata
"""
start_exec_time = time.time()
try:
# Validate video file
video_path = self._validate_file_path(video_url)
self._color_log(f"🎬 Analyzing video: {video_url}", Color.cyan)
self._color_log(f"📋 Question: {question}", Color.blue)
# Extract video frames
video_frames = self._get_video_frames(
str(video_path), sample_rate, start_time, end_time
)
self._color_log(f"📸 Extracted {len(video_frames)} frames", Color.blue)
# Process frames in chunks of 64 frames for parallel analysis
chunk_size = 128
chunks = []
# Create chunks of `chunk_size` continuous frames
for i in range(0, len(video_frames), chunk_size):
chunk_frames = video_frames[i : i + chunk_size]
chunks.append((i // chunk_size, chunk_frames, question))
self._color_log(
f"🔄 Processing {len(chunks)} chunks with {max_workers} parallel workers",
Color.blue,
)
# Process chunks in parallel
all_results = [None] * len(chunks) # Pre-allocate to maintain order
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all chunk analysis tasks
future_to_chunk = {
executor.submit(self._analyze_frame_chunk, chunk_data): chunk_data[
0
]
for chunk_data in chunks
}
# Collect results as they complete
for future in as_completed(future_to_chunk):
try:
chunk_index, result = future.result()
all_results[chunk_index] = (
f"Result of video part {chunk_index + 1}: {result}"
)
except Exception as e:
chunk_index = future_to_chunk[future]
self._color_log(
f"❌ Error processing chunk {chunk_index + 1}: {str(e)}",
Color.red,
)
all_results[chunk_index] = (
f"Result of video part {chunk_index + 1}: Analysis failed - {str(e)}"
)
# Filter out None results and join
analysis_result = "\n".join(
[result for result in all_results if result is not None]
)
duration_analyzed = (
end_time - start_time if end_time else len(video_frames) / sample_rate
)
# Create result
result = VideoAnalysisResult(
video_source=video_url,
analysis_result=analysis_result,
frame_count=len(video_frames),
duration_analyzed=duration_analyzed,
success=True,
error=None,
)
# Format output for LLM
message = self._format_analysis_output(result, output_format)
execution_time = time.time() - start_exec_time
# Create metadata
metadata = {
"video_source": video_url,
"frame_count": len(video_frames),
"chunks_processed": len(chunks),
"chunk_size": chunk_size,
"parallel_workers": max_workers,
"duration_analyzed": duration_analyzed,
"sample_rate": sample_rate,
"start_time": start_time,
"end_time": end_time,
"execution_time": execution_time,
"output_format": output_format,
"success": True,
}
self._color_log(
f"✅ Video analysis completed in {execution_time:.2f}s "
f"({len(chunks)} chunks, {len(video_frames)} frames)",
Color.green,
)
return ActionResponse(success=True, message=message, metadata=metadata)
except Exception as e:
execution_time = time.time() - start_exec_time
error_msg = f"Video analysis failed: {str(e)}"
self._color_log(f"{error_msg}", Color.red)
self.logger.error(f"{error_msg}: {traceback.format_exc()}")
return ActionResponse(
success=False,
message=error_msg,
metadata={
"video_source": video_url,
"execution_time": execution_time,
"error": str(e),
"success": False,
},
)
async def mcp_summarize_video(
self,
video_url: str = Field(
description="The input video filepath or URL to summarize."
),
sample_rate: int = Field(
default=1, description="Sample n frames per second (default: 1)."
),
start_time: float = Field(
default=0,
description="Start time of the video segment in seconds (default: 0).",
),
end_time: float | None = Field(
default=None,
description="End time of the video segment in seconds (default: None).",
),
output_format: str = Field(
default="markdown",
description="Output format: 'markdown', 'json', or 'text' (default: markdown).",
),
) -> ActionResponse:
"""Summarize the main content of a video using AI analysis.
This tool provides AI-powered video summarization with:
- Key point extraction
- Main topic identification
- Important visual element recognition
- LLM-optimized result formatting
Args:
video_url: The input video filepath or URL to summarize
sample_rate: Sample n frames per second
start_time: Start time of the video segment in seconds
end_time: End time of the video segment in seconds
output_format: Format for the response output
Returns:
ActionResponse with video summary results and metadata
"""
start_exec_time = time.time()
try:
# Validate video file
video_path = self._validate_file_path(video_url)
self._color_log(f"🎬 Summarizing video: {video_url}", Color.cyan)
# Extract video frames
video_frames = self._get_video_frames(
str(video_path), sample_rate, start_time, end_time
)
self._color_log(f"📸 Extracted {len(video_frames)} frames", Color.blue)
# Process frames in larger chunks for summarization
interval = 490
frame_nums = 500
all_results = []
for i in range(0, len(video_frames), interval):
cur_frames = video_frames[i : i + frame_nums]
content = self._create_video_content(
self.video_summarize_prompt, cur_frames
)
inputs = [{"role": "user", "content": content}]
try:
response: ModelResponse = call_llm_model(
get_llm_model(
conf=AgentConfig(
llm_provider="openai",
llm_model_name=os.getenv(
"VIDEO_LLM_MODEL_NAME", "gpt-4o"
),
llm_api_key=os.getenv("VIDEO_LLM_API_KEY"),
llm_base_url=os.getenv("VIDEO_LLM_BASE_URL"),
)
),
inputs,
temperature=float(os.getenv("VIDEO_LLM_TEMPERATURE", "1.0")),
)
cur_summary = response.content
except Exception as e:
self._color_log(
f"LLM summary error for chunk {i // interval + 1}: {str(e)}",
Color.yellow,
)
cur_summary = (
f"Summary failed for video segment {i // interval + 1}"
)
all_results.append(
f"Summary of video part {i // interval + 1}: {cur_summary}"
)
if i + frame_nums >= len(video_frames):
break
summary_result = "\n".join(all_results)
duration_analyzed = (
end_time - start_time if end_time else len(video_frames) / sample_rate
)
# Create result
result = VideoSummaryResult(
video_source=video_url,
summary=summary_result,
frame_count=len(video_frames),
duration_analyzed=duration_analyzed,
success=True,
error=None,
)
# Format output for LLM
message = self._format_summary_output(result, output_format)
execution_time = time.time() - start_exec_time
# Create metadata
metadata = VideoMetadata(
operation="summarize",
video_source=video_url,
sample_rate=sample_rate,
start_time=start_time,
end_time=end_time,
frame_count=len(video_frames),
execution_time=execution_time,
).model_dump()
self._color_log(
"✅ Video summarization completed successfully", Color.green
)
return ActionResponse(success=True, message=message, metadata=metadata)
except Exception as e:
error_msg = str(e)
self._color_log(
f"❌ Video summarization error: {traceback.format_exc()}", Color.red
)
# Format error for LLM
message = f"Failed to summarize video: {error_msg}"
execution_time = time.time() - start_exec_time
# Create metadata
metadata = VideoMetadata(
operation="summarize",
video_source=video_url,
error_type="summarization_failure",
execution_time=execution_time,
).model_dump()
return ActionResponse(success=False, message=message, metadata=metadata)
async def mcp_extract_keyframes(
self,
video_path: str = Field(description="The input video filepath or URL."),
target_time: int = Field(
description="The specific time point for extraction (in seconds), centered within the window_size."
),
window_size: int = Field(
default=5,
description="The window size for extraction (in seconds, default: 5).",
),
output_dir: str = Field(
default=None,
description="Directory where extracted frames will be saved (default: workspace/keyframes).",
),
output_format: str = Field(
default="markdown",
description="Output format: 'markdown', 'json', or 'text' (default: markdown).",
),
) -> ActionResponse:
"""Extract key frames around a target time with scene detection.
This tool provides keyframe extraction with:
- Scene detection for significant frame changes
- Configurable time windows
- Automatic output directory management
- LLM-optimized result formatting
Args:
video_path: The input video filepath or URL
target_time: Specific time point (in seconds) to extract frames around
window_size: Time window (in seconds) centered on target_time
cleanup: Whether to delete the original video file after processing
output_dir: Directory where extracted frames will be saved
output_format: Format for the response output
Returns:
ActionResponse with keyframe extraction results and metadata
"""
start_exec_time = time.time()
try:
# Validate video file
validated_path = self._validate_file_path(video_path)
# Set default output directory
output_dir = (
str(self.workspace / "keyframes") if output_dir is None else output_dir
)
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
self._color_log(f"🎬 Extracting keyframes from: {video_path}", Color.cyan)
self._color_log(
f"🎯 Target time: {target_time}s, Window: {window_size}s", Color.blue
)
# Extract keyframes with scene detection
frames, frame_times = self._extract_keyframes_with_scene_detection(
str(validated_path), target_time, window_size
)
# Save frames to disk
frame_paths, frame_timestamps = self._save_keyframes(
frames, frame_times, str(output_path)
)
# Cleanup if requested
# if cleanup and validated_path.exists():
# validated_path.unlink()
# self._color_log("🗑️ Cleaned up original video file", Color.yellow)
# Create result
result = KeyframeResult(
frame_paths=frame_paths,
frame_timestamps=frame_timestamps,
output_directory=str(output_path),
frame_count=len(frame_paths),
target_time=float(target_time),
window_size=float(window_size),
success=True,
error=None,
)
# Format output for LLM
message = self._format_keyframe_output(result, output_format)
execution_time = time.time() - start_exec_time
# Create metadata
metadata = VideoMetadata(
operation="extract_keyframes",
video_source=video_path,
target_time=float(target_time),
window_size=float(window_size),
output_directory=str(output_path),
frame_count=len(frame_paths),
execution_time=execution_time,
).model_dump()
self._color_log(
f"✅ Extracted {len(frame_paths)} keyframes successfully", Color.green
)
return ActionResponse(success=True, message=message, metadata=metadata)
except Exception as e:
error_msg = str(e)
self._color_log(
f"❌ Keyframe extraction error: {traceback.format_exc()}", Color.red
)
# Format error for LLM
message = f"Failed to extract keyframes: {error_msg}"
execution_time = time.time() - start_exec_time
# Create metadata
metadata = VideoMetadata(
operation="extract_keyframes",
video_source=video_path,
target_time=float(target_time) if target_time else None,
window_size=float(window_size) if window_size else None,
error_type="keyframe_extraction_failure",
execution_time=execution_time,
).model_dump()
return ActionResponse(success=False, message=message, metadata=metadata)
def _extract_keyframes_with_scene_detection(
self, video_path: str, target_time: int, window_size: int
) -> tuple[list[any], list[float]]:
"""Extract key frames around the target time with scene detection.
Args:
video_path: Path to the video file
target_time: Target time in seconds
window_size: Window size in seconds
Returns:
Tuple of (frames, frame_times)
"""
cap = cv2.VideoCapture(video_path) # pylint: disable=E1101
fps = cap.get(cv2.CAP_PROP_FPS) # pylint: disable=E1101
# Calculate frame numbers for the time window
start_frame = int((target_time - window_size / 2) * fps)
end_frame = int((target_time + window_size / 2) * fps)
total_frames_in_window = end_frame - start_frame
max_frames = 384 # Maximum allowed frames to prevent memory issues
# Calculate sampling interval for even distribution
if total_frames_in_window <= max_frames:
# If total frames is within limit, use scene detection normally
frame_interval = 1
use_scene_detection = True
else:
# If exceeds limit, sample evenly across the window
frame_interval = total_frames_in_window // max_frames
use_scene_detection = False # Skip scene detection for even sampling
frames = []
frame_times = []
# Set video position to start_frame
cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, start_frame)) # pylint: disable=E1101
prev_frame = None
frame_count = 0
while cap.isOpened() and len(frames) < max_frames:
frame_pos = cap.get(cv2.CAP_PROP_POS_FRAMES) # pylint: disable=E1101
if frame_pos >= end_frame:
break
ret, frame = cap.read()
if not ret:
break
if use_scene_detection:
# Use original scene detection logic
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) # pylint: disable=E1101
# If this is the first frame, save it
if prev_frame is None:
frames.append(frame)
frame_times.append(frame_pos / fps)
else:
# Calculate difference between current and previous frame
diff = cv2.absdiff(gray, prev_frame) # pylint: disable=E1101
mean_diff = np.mean(diff)
# If significant change detected, save frame
if mean_diff > 20: # Threshold for scene change
frames.append(frame)
frame_times.append(frame_pos / fps)
prev_frame = gray
else:
# Use even sampling for large windows
if frame_count % frame_interval == 0:
frames.append(frame)
frame_times.append(frame_pos / fps)
frame_count += 1
cap.release()
return frames, frame_times
def _save_keyframes(
self, frames: list[any], frame_times: list[float], output_dir: str
) -> tuple[list[str], list[float]]:
"""Save extracted frames to disk.
Args:
frames: List of frame objects
frame_times: List of frame timestamps
output_dir: Output directory path
Returns:
Tuple of (saved_paths, saved_timestamps)
"""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
saved_paths = []
saved_timestamps = []
for _, (frame, timestamp) in enumerate(zip(frames, frame_times)):
filename = output_path / f"frame_{timestamp:.2f}s.jpg"
cv2.imwrite(str(filename), frame) # pylint: disable=E1101
saved_paths.append(str(filename))
saved_timestamps.append(timestamp)
return saved_paths, saved_timestamps
# Default arguments for testing
if __name__ == "__main__":
load_dotenv()
arguments = ActionArguments(
name="video",
transport="stdio",
workspace=os.getenv("AWORLD_WORKSPACE", "~"),
)
try:
service = VideoCollection(arguments)
service.run()
except Exception as e:
print(f"An error occurred: {str(e)}")