Coverage for agentos/multimodal/__init__.py: 29%
373 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 12:29 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-06 12:29 +0800
1"""
2AgentOS v1.14.3 — Multimodal Context Manager.
4Unified multimodal context layer for AgentOS agents. Handles images,
5audio, video, and structured documents as first-class context objects.
7Features:
8- Multi-format image processing (PNG, JPEG, GIF, WebP, SVG, HEIC)
9- Audio transcription & processing (WAV, MP3, FLAC, M4A)
10- Video keyframe extraction & captioning
11- PDF/DOCX document text extraction with layout awareness
12- File type auto-detection (magic bytes)
13- Image preprocessing pipeline (resize, compress, format convert)
14- Vision LLM adapter for base64 images
15- Thumbnail generation
16- Metadata extraction (EXIF, duration, dimensions)
18Architecture:
19 File Input
20 ├── MediaDetector (magic bytes identification)
21 ├── MediaProcessor (format-specific pipeline)
22 │ ├── ImageProcessor (resize/compress/convert/base64)
23 │ ├── AudioProcessor (transcription via whisper)
24 │ ├── VideoProcessor (keyframe extraction)
25 │ └── DocumentProcessor (PDF/DOCX extraction)
26 └── MediaContext (unified context object)
28Inspired by: GPT-4V multimodal API, Claude Vision, Gemini 1.5 Pro
29"""
31from __future__ import annotations
33import base64
34import json
35import mimetypes
36import os
37import subprocess
38import tempfile
39import uuid
40from abc import ABC, abstractmethod
41from dataclasses import dataclass, field
42from enum import StrEnum
43from pathlib import Path
44from typing import (
45 Any,
46)
48# ── Types ───────────────────────────────────
51class MediaType(StrEnum):
52 IMAGE = "image"
53 AUDIO = "audio"
54 VIDEO = "video"
55 DOCUMENT = "document"
56 UNKNOWN = "unknown"
59class ImageFormat(StrEnum):
60 PNG = "png"
61 JPEG = "jpeg"
62 GIF = "gif"
63 WEBP = "webp"
64 SVG = "svg"
65 BMP = "bmp"
66 HEIC = "heic"
67 TIFF = "tiff"
70@dataclass
71class MediaMetadata:
72 """媒体文件元数据。"""
74 file_path: str = ""
75 media_type: MediaType = MediaType.UNKNOWN
76 mime_type: str = ""
77 file_size_bytes: int = 0
79 # Image
80 width: int = 0
81 height: int = 0
82 color_mode: str = ""
84 # Audio/Video
85 duration_s: float = 0.0
86 sample_rate: int = 0
87 channels: int = 0
88 bitrate_kbps: int = 0
90 # General
91 has_alpha: bool = False
92 page_count: int = 0
93 extra: dict[str, Any] = field(default_factory=dict)
95 def to_dict(self) -> dict:
96 return {
97 "file_path": self.file_path,
98 "media_type": self.media_type.value,
99 "mime_type": self.mime_type,
100 "file_size_bytes": self.file_size_bytes,
101 "width": self.width,
102 "height": self.height,
103 "duration_s": self.duration_s,
104 "page_count": self.page_count,
105 }
108@dataclass
109class MediaContext:
110 """统一的多模态上下文对象。
112 This is what gets passed to LLM context windows.
113 """
115 context_id: str = field(default_factory=lambda: f"mctx-{uuid.uuid4().hex[:12]}")
116 media_type: MediaType = MediaType.UNKNOWN
117 metadata: MediaMetadata = field(default_factory=MediaMetadata)
119 # Processed representations
120 text_description: str = "" # 自然语言描述
121 base64_data: str = "" # Base64 编码(用于视觉 LLM)
122 extracted_text: str = "" # OCR/转录文本
123 thumbnail_path: str = "" # 缩略图路径
125 # Structured
126 entities: list[dict[str, Any]] = field(default_factory=list)
127 captions: list[str] = field(default_factory=list)
129 def to_llm_message(self) -> dict:
130 """转换为 LLM API 消息格式。"""
131 if self.media_type == MediaType.IMAGE and self.base64_data:
132 return {
133 "role": "user",
134 "content": [
135 {
136 "type": "image_url",
137 "image_url": {
138 "url": f"data:{self.metadata.mime_type};base64,{self.base64_data}",
139 "detail": "auto",
140 },
141 },
142 {
143 "type": "text",
144 "text": self.text_description or "Describe this image.",
145 },
146 ],
147 }
148 return {
149 "role": "user",
150 "content": self.text_description or self.extracted_text or "",
151 }
154# ── Media Detector ──────────────────────────
157class MediaDetector:
158 """通过文件魔数 (magic bytes) 检测媒体类型。
160 Usage:
161 detector = MediaDetector()
162 mt = detector.detect("photo.jpg") # MediaType.IMAGE
163 """
165 # Magic bytes signatures
166 MAGIC_SIGNATURES = {
167 b"\xff\xd8\xff": (MediaType.IMAGE, ImageFormat.JPEG),
168 b"\x89PNG\r\n\x1a\n": (MediaType.IMAGE, ImageFormat.PNG),
169 b"GIF87a": (MediaType.IMAGE, ImageFormat.GIF),
170 b"GIF89a": (MediaType.IMAGE, ImageFormat.GIF),
171 b"RIFF": (MediaType.IMAGE, ImageFormat.WEBP), # WEBP is RIFF{size}WEBP
172 b"\x42\x4d": (MediaType.IMAGE, ImageFormat.BMP),
173 b"<?xml": (MediaType.IMAGE, ImageFormat.SVG),
174 b"<svg": (MediaType.IMAGE, ImageFormat.SVG),
175 b"II*\x00": (MediaType.IMAGE, ImageFormat.TIFF),
176 b"MM\x00*": (MediaType.IMAGE, ImageFormat.TIFF),
177 # Audio
178 b"RIFF": (MediaType.AUDIO, None), # WAV is RIFF
179 b"ID3": (MediaType.AUDIO, None), # MP3 with ID3
180 b"\xff\xfb": (MediaType.AUDIO, None), # MP3
181 b"\xff\xf3": (MediaType.AUDIO, None), # MP3
182 b"fLaC": (MediaType.AUDIO, None), # FLAC
183 b"OggS": (MediaType.AUDIO, None), # OGG
184 # Video
185 b"\x00\x00\x00\x18ftyp": (MediaType.VIDEO, None), # MP4
186 b"\x00\x00\x00\x20ftyp": (MediaType.VIDEO, None),
187 b"\x1a\x45\xdf\xa3": (MediaType.VIDEO, None), # WebM/MKV
188 # Documents
189 b"%PDF": (MediaType.DOCUMENT, None),
190 b"PK\x03\x04": (MediaType.DOCUMENT, None), # DOCX/XLSX/PPTX (ZIP)
191 }
193 # Audio extensions
194 AUDIO_EXTENSIONS = {".mp3", ".wav", ".flac", ".m4a", ".ogg", ".aac", ".wma", ".opus"}
196 # Video extensions
197 VIDEO_EXTENSIONS = {".mp4", ".avi", ".mkv", ".mov", ".wmv", ".webm", ".flv", ".m4v", ".3gp"}
199 # Image extensions
200 IMAGE_EXTENSIONS = {
201 ".jpg",
202 ".jpeg",
203 ".png",
204 ".gif",
205 ".webp",
206 ".bmp",
207 ".svg",
208 ".tiff",
209 ".heic",
210 ".ico",
211 }
213 # Document extensions
214 DOCUMENT_EXTENSIONS = {
215 ".pdf",
216 ".docx",
217 ".doc",
218 ".xlsx",
219 ".xls",
220 ".pptx",
221 ".ppt",
222 ".txt",
223 ".md",
224 ".html",
225 ".epub",
226 }
228 @classmethod
229 def detect(cls, file_path: str) -> MediaType:
230 """检测文件媒体类型。"""
231 ext = Path(file_path).suffix.lower()
233 if ext in cls.IMAGE_EXTENSIONS:
234 return MediaType.IMAGE
235 if ext in cls.AUDIO_EXTENSIONS:
236 return MediaType.AUDIO
237 if ext in cls.VIDEO_EXTENSIONS:
238 return MediaType.VIDEO
239 if ext in cls.DOCUMENT_EXTENSIONS:
240 return MediaType.DOCUMENT
242 # Fallback to magic bytes
243 try:
244 with open(file_path, "rb") as f:
245 header = f.read(32)
246 except Exception:
247 return MediaType.UNKNOWN
249 for magic, (mtype, _) in cls.MAGIC_SIGNATURES.items():
250 if header.startswith(magic):
251 # RIFF ambiguity resolution
252 if magic == b"RIFF":
253 if b"WEBP" in header:
254 return MediaType.IMAGE
255 if b"WAVE" in header:
256 return MediaType.AUDIO
257 return mtype
259 # MIME type fallback
260 mime, _ = mimetypes.guess_type(file_path)
261 if mime:
262 if mime.startswith("image/"):
263 return MediaType.IMAGE
264 if mime.startswith("audio/"):
265 return MediaType.AUDIO
266 if mime.startswith("video/"):
267 return MediaType.VIDEO
269 return MediaType.UNKNOWN
271 @classmethod
272 def batch_detect(cls, file_paths: list[str]) -> dict[str, MediaType]:
273 """批量检测。"""
274 return {fp: cls.detect(fp) for fp in file_paths}
277# ── Media Processors ────────────────────────
280class MediaProcessor(ABC):
281 """媒体处理器基类。"""
283 @abstractmethod
284 def process(self, file_path: str) -> MediaContext: ...
286 @abstractmethod
287 def extract_metadata(self, file_path: str) -> MediaMetadata: ...
290class ImageProcessor(MediaProcessor):
291 """图像处理器。
293 支持格式转换、缩放、压缩、Base64 编码。
295 Usage:
296 processor = ImageProcessor()
297 ctx = processor.process("photo.jpg")
298 base64_str = ctx.base64_data # 可直接用于 LLM API
299 """
301 def __init__(
302 self,
303 max_size: int = 2048,
304 quality: int = 85,
305 output_format: str = "JPEG",
306 ):
307 self._max_size = max_size
308 self._quality = quality
309 self._output_format = output_format
311 def process(self, file_path: str) -> MediaContext:
312 ctx = MediaContext(media_type=MediaType.IMAGE)
313 ctx.metadata = self.extract_metadata(file_path)
314 ctx.base64_data = self._encode_base64(file_path)
315 ctx.text_description = self._generate_description(file_path)
316 ctx.thumbnail_path = self._generate_thumbnail(file_path)
317 return ctx
319 def extract_metadata(self, file_path: str) -> MediaMetadata:
320 meta = MediaMetadata(
321 file_path=file_path,
322 media_type=MediaType.IMAGE,
323 mime_type=mimetypes.guess_type(file_path)[0] or "application/octet-stream",
324 file_size_bytes=os.path.getsize(file_path) if os.path.exists(file_path) else 0,
325 )
327 # Try to get dimensions using PIL
328 try:
329 from PIL import Image
331 with Image.open(file_path) as img:
332 meta.width = img.width
333 meta.height = img.height
334 meta.color_mode = img.mode
335 meta.has_alpha = img.mode in ("RGBA", "LA", "PA")
337 # EXIF extraction
338 exif = img.getexif()
339 if exif:
340 for tag_id, value in exif.items():
341 meta.extra[str(tag_id)] = str(value)
342 except ImportError:
343 pass
344 except Exception:
345 pass
347 return meta
349 def _encode_base64(self, file_path: str) -> str:
350 """将图片编码为 Base64。"""
351 try:
352 with open(file_path, "rb") as f:
353 return base64.b64encode(f.read()).decode("utf-8")
354 except Exception:
355 return ""
357 def _generate_description(self, file_path: str) -> str:
358 """生成图片自然语言描述(应由视觉 LLM 生成)。"""
359 meta = self.extract_metadata(file_path)
360 return f"Image: {meta.width}x{meta.height}, format: {Path(file_path).suffix}"
362 def _generate_thumbnail(self, file_path: str) -> str:
363 """生成缩略图。"""
364 try:
365 from PIL import Image
367 thumb_dir = Path(tempfile.gettempdir()) / "agentos_thumbnails"
368 thumb_dir.mkdir(exist_ok=True)
370 thumb_name = f"thumb_{uuid.uuid4().hex[:8]}.jpg"
371 thumb_path = thumb_dir / thumb_name
373 with Image.open(file_path) as img:
374 img.thumbnail((self._max_size, self._max_size))
375 img.convert("RGB").save(thumb_path, self._output_format, quality=self._quality)
377 return str(thumb_path)
378 except Exception:
379 return ""
381 def resize(
382 self, file_path: str, width: int, height: int, output_path: str | None = None
383 ) -> str:
384 """缩放图片。"""
385 try:
386 from PIL import Image
388 out = output_path or str(
389 Path(tempfile.gettempdir())
390 / f"resized_{uuid.uuid4().hex[:8]}{Path(file_path).suffix}"
391 )
393 with Image.open(file_path) as img:
394 img.resize((width, height), Image.LANCZOS).save(out)
396 return out
397 except Exception as e:
398 raise RuntimeError(f"Image resize failed: {e}")
400 def compress(
401 self,
402 file_path: str,
403 quality: int = 70,
404 output_path: str | None = None,
405 ) -> str:
406 """压缩图片。"""
407 try:
408 from PIL import Image
410 out = output_path or str(
411 Path(tempfile.gettempdir()) / f"compressed_{uuid.uuid4().hex[:8]}.jpg"
412 )
414 with Image.open(file_path) as img:
415 img.convert("RGB").save(out, "JPEG", quality=quality, optimize=True)
417 return out
418 except Exception as e:
419 raise RuntimeError(f"Image compression failed: {e}")
421 def convert_format(
422 self, file_path: str, target_format: str, output_path: str | None = None
423 ) -> str:
424 """转换图片格式。"""
425 try:
426 from PIL import Image
428 fmt = target_format.upper().replace(".", "")
429 ext = f".{target_format.lower().lstrip('.')}"
430 out = output_path or str(
431 Path(tempfile.gettempdir()) / f"converted_{uuid.uuid4().hex[:8]}{ext}"
432 )
434 with Image.open(file_path) as img:
435 img.save(out, fmt)
437 return out
438 except Exception as e:
439 raise RuntimeError(f"Format conversion failed: {e}")
442class AudioProcessor(MediaProcessor):
443 """音频处理器。
445 支持转录(需 whisper)、格式转换、元数据提取。
447 Usage:
448 processor = AudioProcessor()
449 ctx = processor.process("recording.mp3")
450 print(ctx.extracted_text) # 转录文本
451 """
453 def __init__(self, transcription_model: str = "base"):
454 self._model = transcription_model
456 def process(self, file_path: str) -> MediaContext:
457 ctx = MediaContext(media_type=MediaType.AUDIO)
458 ctx.metadata = self.extract_metadata(file_path)
459 ctx.extracted_text = self._transcribe(file_path)
460 return ctx
462 def extract_metadata(self, file_path: str) -> MediaMetadata:
463 meta = MediaMetadata(
464 file_path=file_path,
465 media_type=MediaType.AUDIO,
466 mime_type=mimetypes.guess_type(file_path)[0] or "application/octet-stream",
467 file_size_bytes=os.path.getsize(file_path) if os.path.exists(file_path) else 0,
468 )
470 # Extract with ffprobe if available
471 try:
472 result = subprocess.run(
473 [
474 "ffprobe",
475 "-v",
476 "quiet",
477 "-print_format",
478 "json",
479 "-show_format",
480 "-show_streams",
481 file_path,
482 ],
483 capture_output=True,
484 text=True,
485 timeout=10,
486 )
487 if result.returncode == 0:
488 info = json.loads(result.stdout)
489 fmt = info.get("format", {})
490 meta.duration_s = float(fmt.get("duration", 0))
491 meta.bitrate_kbps = int(int(fmt.get("bit_rate", 0)) / 1000)
493 for stream in info.get("streams", []):
494 if stream.get("codec_type") == "audio":
495 meta.sample_rate = int(stream.get("sample_rate", 0))
496 meta.channels = int(stream.get("channels", 0))
497 break
498 except Exception:
499 pass
501 return meta
503 def _transcribe(self, file_path: str) -> str:
504 """音频转录。"""
505 try:
506 import whisper
508 model = whisper.load_model(self._model)
509 result = model.transcribe(file_path)
510 return result["text"]
511 except ImportError:
512 return "[Transcription requires: pip install openai-whisper]"
513 except Exception as e:
514 return f"[Transcription error: {e}]"
517class VideoProcessor(MediaProcessor):
518 """视频处理器。
520 提取关键帧、生成描述。
522 Usage:
523 processor = VideoProcessor()
524 ctx = processor.process("demo.mp4")
525 for caption in ctx.captions:
526 print(caption)
527 """
529 def __init__(self, keyframe_interval_s: float = 2.0, max_keyframes: int = 10):
530 self._keyframe_interval = keyframe_interval_s
531 self._max_keyframes = max_keyframes
532 self._image_processor = ImageProcessor()
534 def process(self, file_path: str) -> MediaContext:
535 ctx = MediaContext(media_type=MediaType.VIDEO)
536 ctx.metadata = self.extract_metadata(file_path)
537 ctx.captions = self._extract_keyframes(file_path)
538 return ctx
540 def extract_metadata(self, file_path: str) -> MediaMetadata:
541 meta = MediaMetadata(
542 file_path=file_path,
543 media_type=MediaType.VIDEO,
544 mime_type=mimetypes.guess_type(file_path)[0] or "application/octet-stream",
545 file_size_bytes=os.path.getsize(file_path) if os.path.exists(file_path) else 0,
546 )
548 try:
549 result = subprocess.run(
550 [
551 "ffprobe",
552 "-v",
553 "quiet",
554 "-print_format",
555 "json",
556 "-show_format",
557 "-show_streams",
558 file_path,
559 ],
560 capture_output=True,
561 text=True,
562 timeout=10,
563 )
564 if result.returncode == 0:
565 info = json.loads(result.stdout)
566 fmt = info.get("format", {})
567 meta.duration_s = float(fmt.get("duration", 0))
568 meta.bitrate_kbps = int(int(fmt.get("bit_rate", 0)) / 1000)
570 for stream in info.get("streams", []):
571 if stream.get("codec_type") == "video":
572 meta.width = int(stream.get("width", 0))
573 meta.height = int(stream.get("height", 0))
574 break
575 except Exception:
576 pass
578 return meta
580 def _extract_keyframes(self, file_path: str) -> list[str]:
581 """提取视频关键帧。"""
582 captions = []
583 meta = self.extract_metadata(file_path)
584 duration = meta.duration_s
586 if duration == 0:
587 return captions
589 num_frames = min(
590 int(duration / self._keyframe_interval),
591 self._max_keyframes,
592 )
594 thumb_dir = Path(tempfile.gettempdir()) / "agentos_video_frames"
595 thumb_dir.mkdir(exist_ok=True)
597 for i in range(num_frames):
598 timestamp = i * self._keyframe_interval
599 frame_path = thumb_dir / f"frame_{uuid.uuid4().hex[:8]}.jpg"
601 try:
602 subprocess.run(
603 [
604 "ffmpeg",
605 "-y",
606 "-loglevel",
607 "quiet",
608 "-ss",
609 str(timestamp),
610 "-i",
611 file_path,
612 "-vframes",
613 "1",
614 "-q:v",
615 "2",
616 str(frame_path),
617 ],
618 timeout=30,
619 check=True,
620 )
622 if frame_path.exists():
623 # Encode frame as base64
624 ctx = self._image_processor.process(str(frame_path))
625 captions.append(
626 f"[{self._format_time(timestamp)}] {ctx.text_description} "
627 f"base64:{ctx.base64_data[:50]}..."
628 )
629 # Cleanup frame file
630 frame_path.unlink(missing_ok=True)
631 except Exception:
632 pass
634 return captions
636 @staticmethod
637 def _format_time(seconds: float) -> str:
638 m, s = divmod(int(seconds), 60)
639 h, m = divmod(m, 60)
640 if h:
641 return f"{h}:{m:02d}:{s:02d}"
642 return f"{m}:{s:02d}"
645class DocumentProcessor(MediaProcessor):
646 """文档处理器。
648 从 PDF/DOCX 等文档中提取文本。
650 Usage:
651 processor = DocumentProcessor()
652 ctx = processor.process("report.pdf")
653 print(ctx.extracted_text[:500])
654 """
656 def process(self, file_path: str) -> MediaContext:
657 ctx = MediaContext(media_type=MediaType.DOCUMENT)
658 ctx.metadata = self.extract_metadata(file_path)
659 ctx.extracted_text = self._extract_text(file_path)
660 return ctx
662 def extract_metadata(self, file_path: str) -> MediaMetadata:
663 return MediaMetadata(
664 file_path=file_path,
665 media_type=MediaType.DOCUMENT,
666 mime_type=mimetypes.guess_type(file_path)[0] or "application/octet-stream",
667 file_size_bytes=os.path.getsize(file_path) if os.path.exists(file_path) else 0,
668 )
670 def _extract_text(self, file_path: str) -> str:
671 """提取文档文本。"""
672 ext = Path(file_path).suffix.lower()
674 if ext == ".pdf":
675 return self._extract_pdf(file_path)
676 elif ext in (".docx", ".doc"):
677 return self._extract_docx(file_path)
678 elif ext in (".txt", ".md", ".py", ".json", ".yaml", ".xml", ".html", ".csv"):
679 try:
680 return Path(file_path).read_text(encoding="utf-8")
681 except Exception:
682 return Path(file_path).read_text(encoding="latin-1")
683 else:
684 return f"[Unsupported document format: {ext}]"
686 def _extract_pdf(self, file_path: str) -> str:
687 """从 PDF 中提取文本。"""
688 try:
689 import fitz # PyMuPDF
691 doc = fitz.open(file_path)
692 text_parts = []
693 for page_num in range(len(doc)):
694 page = doc[page_num]
695 text = page.get_text()
696 if text.strip():
697 text_parts.append(f"--- Page {page_num + 1} ---\n{text}")
698 doc.close()
699 return "\n\n".join(text_parts) if text_parts else "[No extractable text in PDF]"
700 except ImportError:
701 try:
702 result = subprocess.run(
703 ["pdftotext", file_path, "-"],
704 capture_output=True,
705 text=True,
706 timeout=30,
707 )
708 if result.returncode == 0:
709 return result.stdout
710 except Exception:
711 pass
712 return "[PDF extraction requires: pip install PyMuPDF]"
713 except Exception as e:
714 return f"[PDF extraction error: {e}]"
716 def _extract_docx(self, file_path: str) -> str:
717 """从 DOCX 中提取文本。"""
718 try:
719 from docx import Document
721 doc = Document(file_path)
722 paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
723 return "\n\n".join(paragraphs) if paragraphs else "[No text in document]"
724 except ImportError:
725 return "[DOCX extraction requires: pip install python-docx]"
726 except Exception as e:
727 return f"[DOCX extraction error: {e}]"
730# ── Multimodal Context Manager ──────────────
733class MultimodalContextManager:
734 """多模态上下文管理器。
736 统一入口:接收文件路径,返回 MediaContext。
738 Usage:
739 mgr = MultimodalContextManager()
740 ctx = mgr.load("photo.jpg")
741 message = ctx.to_llm_message()
742 """
744 def __init__(self):
745 self._detector = MediaDetector()
746 self._processors: dict[MediaType, MediaProcessor] = {
747 MediaType.IMAGE: ImageProcessor(),
748 MediaType.AUDIO: AudioProcessor(),
749 MediaType.VIDEO: VideoProcessor(),
750 MediaType.DOCUMENT: DocumentProcessor(),
751 }
753 def load(self, file_path: str) -> MediaContext:
754 """加载并处理单个媒体文件。"""
755 mtype = self._detector.detect(file_path)
756 processor = self._processors.get(mtype)
758 if not processor:
759 ctx = MediaContext(media_type=MediaType.UNKNOWN)
760 ctx.metadata = MediaMetadata(file_path=file_path, media_type=MediaType.UNKNOWN)
761 ctx.extracted_text = f"[Unsupported media type: {mtype}]"
762 return ctx
764 return processor.process(file_path)
766 def load_batch(self, file_paths: list[str]) -> list[MediaContext]:
767 """批量加载。"""
768 return [self.load(fp) for fp in file_paths]
770 def load_as_message(self, file_path: str) -> dict:
771 """加载并转换为 LLM 消息格式。"""
772 return self.load(file_path).to_llm_message()
774 def load_batch_as_messages(self, file_paths: list[str]) -> list[dict]:
775 """批量加载为 LLM 消息。"""
776 return [self.load_as_message(fp) for fp in file_paths]
778 def register_processor(self, media_type: MediaType, processor: MediaProcessor) -> None:
779 """注册自定义处理器。"""
780 self._processors[media_type] = processor
782 def analyze_directory(self, directory: str) -> dict[str, list[str]]:
783 """分析目录中的媒体文件分布。"""
784 result: dict[str, list[str]] = {
785 "images": [],
786 "audio": [],
787 "video": [],
788 "documents": [],
789 "unknown": [],
790 }
792 dir_path = Path(directory)
793 if not dir_path.exists():
794 return result
796 for file_path in dir_path.rglob("*"):
797 if not file_path.is_file():
798 continue
800 mtype = self._detector.detect(str(file_path))
802 if mtype == MediaType.IMAGE:
803 result["images"].append(str(file_path))
804 elif mtype == MediaType.AUDIO:
805 result["audio"].append(str(file_path))
806 elif mtype == MediaType.VIDEO:
807 result["video"].append(str(file_path))
808 elif mtype == MediaType.DOCUMENT:
809 result["documents"].append(str(file_path))
810 else:
811 result["unknown"].append(str(file_path))
813 return result
816# ── Quick Start ─────────────────────────────
819def create_multimodal_manager() -> MultimodalContextManager:
820 """快速创建多模态上下文管理器。"""
821 return MultimodalContextManager()
824def quick_load(file_path: str) -> MediaContext:
825 """快速加载单个文件。"""
826 return MultimodalContextManager().load(file_path)
829# ── Compatibility aliases (required by agentos/__init__.py) ──
831MultimodalManager = MultimodalContextManager
832Modality = MediaType