2160 lines
67 KiB
Markdown
2160 lines
67 KiB
Markdown
# Majoor Assets Manager - Plugin System Design Document
|
|
|
|
**Document Type:** Architecture Design
|
|
**Version:** 1.0.0
|
|
**Date:** March 16, 2026
|
|
**Author:** Architecture Team
|
|
**Status:** ✅ **Implemented** (as of April 2026, v2.4.4+)
|
|
|
|
> **Note**: This design has been fully implemented. See [`PLUGIN_SYSTEM_IMPLEMENTATION_SUMMARY.md`](PLUGIN_SYSTEM_IMPLEMENTATION_SUMMARY.md) for implementation details and [`PLUGIN_QUICK_REFERENCE.md`](PLUGIN_QUICK_REFERENCE.md) for developer quick reference.
|
|
|
|
---
|
|
|
|
## 📋 Table of Contents
|
|
|
|
1. [Executive Summary](#executive-summary)
|
|
2. [Current Architecture Analysis](#current-architecture-analysis)
|
|
3. [Plugin System Architecture](#plugin-system-architecture)
|
|
4. [Implementation Plan](#implementation-plan)
|
|
5. [API Reference](#api-reference)
|
|
6. [Security Model](#security-model)
|
|
7. [Testing Strategy](#testing-strategy)
|
|
8. [Migration Guide](#migration-guide)
|
|
9. [Appendices](#appendices)
|
|
|
|
---
|
|
|
|
## 📌 Executive Summary
|
|
|
|
### **Objective**
|
|
|
|
Design and implement a **plugin system** for the Majoor Assets Manager that allows third-party developers to create custom metadata extractors without modifying the core codebase.
|
|
|
|
### **Problem Statement**
|
|
|
|
The current metadata extraction system is **hardcoded** with fixed extractors for known formats (PNG, WEBP, video, audio). This creates limitations:
|
|
|
|
- ❌ Cannot extract custom node-specific metadata (WanVideo, rgthree, etc.)
|
|
- ❌ Cannot support new file formats without code changes
|
|
- ❌ Cannot add custom parsing logic for proprietary schemas
|
|
- ❌ Requires core codebase modification for any new extractor
|
|
|
|
### **Solution**
|
|
|
|
Implement a **plugin architecture** with:
|
|
|
|
- ✅ Well-defined plugin interface (abstract base class)
|
|
- ✅ Auto-discovery mechanism (scan plugin directories)
|
|
- ✅ Priority-based extractor selection
|
|
- ✅ Sandboxed execution environment
|
|
- ✅ Hot-reload capability for development
|
|
|
|
### **Benefits**
|
|
|
|
| Benefit | Impact |
|
|
|---------|--------|
|
|
| **Extensibility** | Add formats without core changes |
|
|
| **Community** | Share extractors as separate packages |
|
|
| **Isolation** | Plugin bugs don't crash core system |
|
|
| **Testing** | Test extractors independently |
|
|
| **Versioning** | Independent release cycles |
|
|
|
|
---
|
|
|
|
## 🔍 Current Architecture Analysis
|
|
|
|
### **Current Metadata Extraction Flow**
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ MetadataService.extract() │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ File Type Detection (image/video/audio/3d) │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Hardcoded Extractor Lookup │
|
|
│ - extract_png_metadata() │
|
|
│ - extract_webp_metadata() │
|
|
│ - extract_video_metadata() │
|
|
│ - extract_audio_metadata() │
|
|
│ - extract_3d_model_metadata() │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Metadata Normalization & Storage │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### **Current Extractor Registry**
|
|
|
|
**File:** `mjr_am_backend/features/metadata/extractor_registry.py`
|
|
|
|
```python
|
|
# Current implementation - hardcoded extractors
|
|
|
|
EXTRACTORS = {
|
|
"png": extract_png_metadata,
|
|
"webp": extract_webp_metadata,
|
|
"video": extract_video_metadata,
|
|
"audio": extract_audio_metadata,
|
|
"model3d": extract_3d_model_metadata,
|
|
}
|
|
|
|
def get_extractor(file_type: str):
|
|
return EXTRACTORS.get(file_type)
|
|
```
|
|
|
|
### **Limitations Identified**
|
|
|
|
1. **No Extension Points** - Cannot inject custom extractors
|
|
2. **Tight Coupling** - Extractors imported directly in service
|
|
3. **No Priority System** - All extractors treated equally
|
|
4. **No Validation** - No plugin safety checks
|
|
5. **No Discovery** - Manual registration required
|
|
|
|
---
|
|
|
|
## 🏗️ Plugin System Architecture
|
|
|
|
### **High-Level Architecture**
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ ComfyUI Runtime │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Majoor Assets Manager │
|
|
│ ┌────────────────────────────────────────────────────────────┐ │
|
|
│ │ MetadataService (Modified) │ │
|
|
│ │ ┌──────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ PluginLoader (NEW) │ │ │
|
|
│ │ │ - discover_plugins() │ │ │
|
|
│ │ │ - validate_plugins() │ │ │
|
|
│ │ │ - get_extractor(filepath) │ │ │
|
|
│ │ └──────────────────────────────────────────────────────┘ │ │
|
|
│ │ ┌──────────────────────────────────────────────────────┐ │ │
|
|
│ │ │ Built-in Extractors │ │ │
|
|
│ │ │ - PNG/WEBP extractor │ │ │
|
|
│ │ │ - Video extractor (FFprobe) │ │ │
|
|
│ │ │ - Audio extractor │ │ │
|
|
│ │ └──────────────────────────────────────────────────────┘ │ │
|
|
│ └────────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
│
|
|
▼
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Plugin Directory │
|
|
│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
|
|
│ │ wanvideo_plugin │ │ rgthree_plugin │ │ custom_plugin │ │
|
|
│ │ ──────────────── │ │ ──────────────── │ │ ───────────── │ │
|
|
│ │ priority: 100 │ │ priority: 50 │ │ priority: 10 │ │
|
|
│ │ extensions: .png │ │ extensions: .png │ │ extensions: │ │
|
|
│ └──────────────────┘ └──────────────────┘ └───────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### **Component Diagram**
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ Plugin System Components │
|
|
├─────────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ ┌────────────────────┐ │
|
|
│ │ Plugin Interface │ Abstract base class defining contract │
|
|
│ │ (ABC) │ │
|
|
│ └─────────┬──────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌────────────────────┐ │
|
|
│ │ Plugin Loader │ Discovery, validation, loading │
|
|
│ └─────────┬──────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌────────────────────┐ │
|
|
│ │ Plugin Registry │ Runtime registry of loaded plugins │
|
|
│ └─────────┬──────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌────────────────────┐ │
|
|
│ │ Plugin Manager │ Lifecycle management, hot-reload │
|
|
│ └─────────┬──────────┘ │
|
|
│ │ │
|
|
│ ▼ │
|
|
│ ┌────────────────────┐ │
|
|
│ │ Security Validator│ Sandboxing, permission checks │
|
|
│ └────────────────────┘ │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### **Directory Structure**
|
|
|
|
```
|
|
ComfyUI-Majoor-AssetsManager/
|
|
├── mjr_am_backend/
|
|
│ └── features/
|
|
│ └── metadata/
|
|
│ ├── service.py # Modified to use plugins
|
|
│ ├── extractor_registry.py # Keep built-in extractors
|
|
│ └── plugin_system/ # NEW: Plugin system
|
|
│ ├── __init__.py
|
|
│ ├── base.py # Plugin interface (ABC)
|
|
│ ├── loader.py # Plugin discovery & loading
|
|
│ ├── registry.py # Runtime plugin registry
|
|
│ ├── manager.py # Lifecycle management
|
|
│ ├── validator.py # Security validation
|
|
│ └── sandbox.py # Execution sandboxing
|
|
│
|
|
├── plugins/ # NEW: Plugin directory
|
|
│ ├── __init__.py
|
|
│ ├── README.md
|
|
│ └── examples/
|
|
│ ├── wanvideo_extractor.py
|
|
│ ├── rgthree_extractor.py
|
|
│ └── custom_node_extractor.py
|
|
│
|
|
├── tests/
|
|
│ └── plugins/ # NEW: Plugin tests
|
|
│ ├── test_plugin_loader.py
|
|
│ ├── test_plugin_validator.py
|
|
│ └── test_plugin_integration.py
|
|
│
|
|
└── docs/
|
|
└── PLUGIN_DEVELOPMENT_GUIDE.md # NEW: Plugin dev guide
|
|
```
|
|
|
|
---
|
|
|
|
## 🛠️ Implementation Plan
|
|
|
|
### **Phase 1: Core Infrastructure** (Week 1)
|
|
|
|
#### **1.1 Define Plugin Interface**
|
|
|
|
**File:** `mjr_am_backend/features/metadata/plugin_system/base.py`
|
|
|
|
```python
|
|
"""
|
|
Plugin System - Base Interface
|
|
|
|
Defines the abstract base class for metadata extractor plugins.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class ExtractorMetadata:
|
|
"""Metadata about an extractor plugin."""
|
|
name: str
|
|
version: str = "1.0.0"
|
|
author: str = "Unknown"
|
|
description: str = ""
|
|
homepage: str = ""
|
|
license: str = "MIT"
|
|
|
|
|
|
@dataclass
|
|
class ExtractionResult:
|
|
"""Result from metadata extraction."""
|
|
success: bool
|
|
data: Dict[str, Any] = field(default_factory=dict)
|
|
error: Optional[str] = None
|
|
extractor_name: str = ""
|
|
confidence: float = 1.0 # 0.0-1.0, for fuzzy matching
|
|
|
|
|
|
class MetadataExtractorPlugin(ABC):
|
|
"""
|
|
Abstract base class for metadata extractor plugins.
|
|
|
|
All plugins must inherit from this class and implement
|
|
the required methods.
|
|
|
|
Example:
|
|
class MyExtractor(MetadataExtractorPlugin):
|
|
@property
|
|
def name(self): return "my_extractor"
|
|
|
|
@property
|
|
def supported_extensions(self): return ['.png', '.webp']
|
|
|
|
async def extract(self, filepath): ...
|
|
"""
|
|
|
|
# ─── Required Properties ───────────────────────────────────────
|
|
|
|
@property
|
|
@abstractmethod
|
|
def name(self) -> str:
|
|
"""
|
|
Unique plugin identifier.
|
|
|
|
Must be lowercase, alphanumeric with underscores.
|
|
Example: "wanvideo_extractor", "rgthree_extractor"
|
|
"""
|
|
pass
|
|
|
|
@property
|
|
@abstractmethod
|
|
def supported_extensions(self) -> List[str]:
|
|
"""
|
|
File extensions this extractor handles.
|
|
|
|
Returns:
|
|
List of extensions including dot, e.g., ['.png', '.webp']
|
|
"""
|
|
pass
|
|
|
|
@property
|
|
@abstractmethod
|
|
def priority(self) -> int:
|
|
"""
|
|
Extraction priority (higher = runs first).
|
|
|
|
Priority levels:
|
|
- 100-999: Custom node-specific extractors
|
|
- 50-99: Format-specific extractors
|
|
- 1-49: Generic/fallback extractors
|
|
"""
|
|
pass
|
|
|
|
# ─── Optional Properties ───────────────────────────────────────
|
|
|
|
@property
|
|
def metadata(self) -> ExtractorMetadata:
|
|
"""Plugin metadata (optional, provides defaults)."""
|
|
return ExtractorMetadata(name=self.name)
|
|
|
|
@property
|
|
def min_compatibility_version(self) -> str:
|
|
"""Minimum Majoor version required (default: "2.4.9")."""
|
|
return "2.4.9"
|
|
|
|
# ─── Required Methods ──────────────────────────────────────────
|
|
|
|
@abstractmethod
|
|
async def extract(self, filepath: str) -> ExtractionResult:
|
|
"""
|
|
Extract metadata from file.
|
|
|
|
Args:
|
|
filepath: Absolute path to file
|
|
|
|
Returns:
|
|
ExtractionResult with success status and data
|
|
|
|
Raises:
|
|
Any exceptions should be caught and returned as
|
|
ExtractionResult(success=False, error=str(exc))
|
|
"""
|
|
pass
|
|
|
|
# ─── Optional Methods ──────────────────────────────────────────
|
|
|
|
def can_extract(self, filepath: str) -> bool:
|
|
"""
|
|
Check if this extractor can handle the file.
|
|
|
|
Default implementation checks file extension.
|
|
Override for more complex detection logic.
|
|
"""
|
|
ext = Path(filepath).suffix.lower()
|
|
return ext in self.supported_extensions
|
|
|
|
async def pre_extract(self, filepath: str) -> bool:
|
|
"""
|
|
Pre-extraction hook (optional).
|
|
|
|
Called before extract(). Return False to skip extraction.
|
|
Useful for quick validation checks.
|
|
"""
|
|
return True
|
|
|
|
async def post_extract(
|
|
self,
|
|
filepath: str,
|
|
result: ExtractionResult
|
|
) -> ExtractionResult:
|
|
"""
|
|
Post-extraction hook (optional).
|
|
|
|
Called after extract(). Can modify result.
|
|
Useful for cleanup or data enrichment.
|
|
"""
|
|
return result
|
|
|
|
async def cleanup(self) -> None:
|
|
"""
|
|
Cleanup hook called on plugin unload.
|
|
|
|
Override to release resources, close connections, etc.
|
|
"""
|
|
pass
|
|
|
|
# ─── Helper Methods ────────────────────────────────────────────
|
|
|
|
def _create_success_result(
|
|
self,
|
|
data: Dict[str, Any],
|
|
confidence: float = 1.0
|
|
) -> ExtractionResult:
|
|
"""Helper to create success result."""
|
|
return ExtractionResult(
|
|
success=True,
|
|
data=data,
|
|
extractor_name=self.name,
|
|
confidence=confidence
|
|
)
|
|
|
|
def _create_error_result(
|
|
self,
|
|
error: str
|
|
) -> ExtractionResult:
|
|
"""Helper to create error result."""
|
|
return ExtractionResult(
|
|
success=False,
|
|
error=error,
|
|
extractor_name=self.name
|
|
)
|
|
```
|
|
|
|
#### **1.2 Implement Plugin Loader**
|
|
|
|
**File:** `mjr_am_backend/features/metadata/plugin_system/loader.py`
|
|
|
|
```python
|
|
"""
|
|
Plugin System - Loader
|
|
|
|
Discovers, validates, and loads plugins from configured directories.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import importlib.util
|
|
import pkgutil
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Set, Type
|
|
import logging
|
|
|
|
from .base import MetadataExtractorPlugin, ExtractorMetadata
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PluginLoadError(Exception):
|
|
"""Raised when plugin loading fails."""
|
|
pass
|
|
|
|
|
|
class PluginLoader:
|
|
"""
|
|
Discovers and loads metadata extractor plugins.
|
|
|
|
Usage:
|
|
loader = PluginLoader([Path("/plugins")])
|
|
loader.discover_plugins()
|
|
extractor = loader.get_extractor("image.png")
|
|
result = await extractor.extract("image.png")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
plugin_dirs: Optional[List[Path]] = None,
|
|
auto_discover: bool = True
|
|
):
|
|
"""
|
|
Initialize plugin loader.
|
|
|
|
Args:
|
|
plugin_dirs: Directories to scan for plugins
|
|
auto_discover: Automatically discover on init
|
|
"""
|
|
self.plugin_dirs = plugin_dirs or self._default_plugin_dirs()
|
|
self._extractors: Dict[str, MetadataExtractorPlugin] = {}
|
|
self._loaded_modules: Set[str] = set()
|
|
self._load_errors: List[tuple[str, str]] = []
|
|
|
|
if auto_discover:
|
|
self.discover_plugins()
|
|
|
|
def _default_plugin_dirs(self) -> List[Path]:
|
|
"""Get default plugin directories."""
|
|
from ...config import OUTPUT_ROOT_PATH
|
|
|
|
return [
|
|
# Bundled plugins
|
|
Path(__file__).parent.parent.parent.parent / "plugins",
|
|
# User plugins (global)
|
|
Path.home() / ".comfyui" / "majoor_plugins" / "extractors",
|
|
# User plugins (local to ComfyUI)
|
|
OUTPUT_ROOT_PATH / "_mjr_plugins" / "extractors",
|
|
]
|
|
|
|
def discover_plugins(self) -> int:
|
|
"""
|
|
Scan plugin directories and load all valid extractors.
|
|
|
|
Returns:
|
|
Number of plugins successfully loaded
|
|
"""
|
|
loaded_count = 0
|
|
|
|
for plugin_dir in self.plugin_dirs:
|
|
if not plugin_dir.exists():
|
|
logger.debug(f"Plugin directory does not exist: {plugin_dir}")
|
|
continue
|
|
|
|
if not plugin_dir.is_dir():
|
|
logger.warning(f"Plugin path is not a directory: {plugin_dir}")
|
|
continue
|
|
|
|
try:
|
|
count = self._scan_directory(plugin_dir)
|
|
loaded_count += count
|
|
logger.info(f"Loaded {count} plugins from {plugin_dir}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to scan {plugin_dir}: {e}")
|
|
self._load_errors.append((str(plugin_dir), str(e)))
|
|
|
|
logger.info(f"Total plugins loaded: {loaded_count}")
|
|
return loaded_count
|
|
|
|
def _scan_directory(self, directory: Path) -> int:
|
|
"""Scan a single directory for plugins."""
|
|
count = 0
|
|
|
|
# Scan .py files
|
|
for module_file in directory.glob("*.py"):
|
|
if module_file.name.startswith("_"):
|
|
continue
|
|
|
|
try:
|
|
self._load_module(module_file)
|
|
count += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to load {module_file}: {e}")
|
|
self._load_errors.append((str(module_file), str(e)))
|
|
|
|
# Scan subdirectories as packages
|
|
for subdir in directory.iterdir():
|
|
if not subdir.is_dir():
|
|
continue
|
|
if subdir.name.startswith("_"):
|
|
continue
|
|
if not (subdir / "__init__.py").exists():
|
|
continue
|
|
|
|
try:
|
|
count += self._scan_package(subdir)
|
|
except Exception as e:
|
|
logger.error(f"Failed to scan package {subdir}: {e}")
|
|
|
|
return count
|
|
|
|
def _scan_package(self, package_dir: Path) -> int:
|
|
"""Scan a package directory for plugins."""
|
|
count = 0
|
|
package_name = package_dir.name
|
|
|
|
for module_file in package_dir.glob("*.py"):
|
|
if module_file.name.startswith("_"):
|
|
continue
|
|
if module_file.name == "__init__.py":
|
|
continue
|
|
|
|
try:
|
|
self._load_module(module_file, package_name=package_name)
|
|
count += 1
|
|
except Exception as e:
|
|
logger.error(f"Failed to load {module_file}: {e}")
|
|
|
|
return count
|
|
|
|
def _load_module(
|
|
self,
|
|
module_file: Path,
|
|
package_name: Optional[str] = None
|
|
) -> None:
|
|
"""Load a single Python module and extract plugins."""
|
|
module_name = module_file.stem
|
|
full_name = f"majoor_plugins.{package_name}.{module_name}" if package_name else f"majoor_plugins.{module_name}"
|
|
|
|
# Skip already loaded modules
|
|
if full_name in self._loaded_modules:
|
|
return
|
|
|
|
# Load module
|
|
spec = importlib.util.spec_from_file_location(full_name, module_file)
|
|
if spec is None or spec.loader is None:
|
|
raise PluginLoadError(f"Cannot load spec for {module_file}")
|
|
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[full_name] = module
|
|
spec.loader.exec_module(module)
|
|
|
|
self._loaded_modules.add(full_name)
|
|
|
|
# Find and instantiate all extractor classes
|
|
for attr_name in dir(module):
|
|
attr = getattr(module, attr_name)
|
|
if self._is_valid_extractor_class(attr):
|
|
try:
|
|
extractor = attr()
|
|
self.register_extractor(extractor)
|
|
except Exception as e:
|
|
logger.error(f"Failed to instantiate {attr_name}: {e}")
|
|
|
|
def _is_valid_extractor_class(self, cls: Type) -> bool:
|
|
"""Check if class is a valid extractor plugin."""
|
|
try:
|
|
return (
|
|
isinstance(cls, type) and
|
|
issubclass(cls, MetadataExtractorPlugin) and
|
|
cls is not MetadataExtractorPlugin
|
|
)
|
|
except TypeError:
|
|
return False
|
|
|
|
def register_extractor(
|
|
self,
|
|
extractor: MetadataExtractorPlugin,
|
|
force: bool = False
|
|
) -> None:
|
|
"""
|
|
Register an extractor instance.
|
|
|
|
Args:
|
|
extractor: Extractor instance to register
|
|
force: Overwrite existing extractor with same name
|
|
"""
|
|
name = extractor.name
|
|
|
|
if name in self._extractors and not force:
|
|
logger.warning(f"Extractor '{name}' already registered, skipping")
|
|
return
|
|
|
|
self._extractors[name] = extractor
|
|
logger.debug(f"Registered extractor: {name}")
|
|
|
|
def unregister_extractor(self, name: str) -> bool:
|
|
"""
|
|
Unregister an extractor by name.
|
|
|
|
Returns:
|
|
True if extractor was found and removed
|
|
"""
|
|
if name not in self._extractors:
|
|
return False
|
|
|
|
extractor = self._extractors.pop(name)
|
|
try:
|
|
# Call cleanup hook
|
|
import asyncio
|
|
loop = asyncio.get_event_loop()
|
|
loop.run_until_complete(extractor.cleanup())
|
|
except Exception as e:
|
|
logger.error(f"Cleanup failed for {name}: {e}")
|
|
|
|
logger.debug(f"Unregistered extractor: {name}")
|
|
return True
|
|
|
|
def get_extractor(self, filepath: str) -> Optional[MetadataExtractorPlugin]:
|
|
"""
|
|
Get the best extractor for a file (by priority).
|
|
|
|
Args:
|
|
filepath: Path to file
|
|
|
|
Returns:
|
|
Best matching extractor or None
|
|
"""
|
|
matching = [
|
|
ext for ext in self._extractors.values()
|
|
if ext.can_extract(filepath)
|
|
]
|
|
|
|
if not matching:
|
|
return None
|
|
|
|
# Return highest priority extractor
|
|
return max(matching, key=lambda e: e.priority)
|
|
|
|
def get_extractor_by_name(self, name: str) -> Optional[MetadataExtractorPlugin]:
|
|
"""Get extractor by name."""
|
|
return self._extractors.get(name)
|
|
|
|
@property
|
|
def all_extractors(self) -> List[MetadataExtractorPlugin]:
|
|
"""Get all registered extractors (sorted by priority)."""
|
|
return sorted(
|
|
self._extractors.values(),
|
|
key=lambda e: e.priority,
|
|
reverse=True
|
|
)
|
|
|
|
@property
|
|
def extractor_names(self) -> List[str]:
|
|
"""Get names of all registered extractors."""
|
|
return list(self._extractors.keys())
|
|
|
|
@property
|
|
def load_errors(self) -> List[tuple[str, str]]:
|
|
"""Get list of (path, error) tuples for failed loads."""
|
|
return self._load_errors.copy()
|
|
|
|
def get_stats(self) -> Dict[str, any]:
|
|
"""Get loader statistics."""
|
|
return {
|
|
"total_extractors": len(self._extractors),
|
|
"loaded_modules": len(self._loaded_modules),
|
|
"load_errors": len(self._load_errors),
|
|
"plugin_dirs": [str(d) for d in self.plugin_dirs],
|
|
}
|
|
```
|
|
|
|
#### **1.3 Implement Plugin Registry**
|
|
|
|
**File:** `mjr_am_backend/features/metadata/plugin_system/registry.py`
|
|
|
|
```python
|
|
"""
|
|
Plugin System - Registry
|
|
|
|
Runtime registry for plugin state and configuration.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field, asdict
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
import logging
|
|
|
|
from .base import MetadataExtractorPlugin, ExtractorMetadata
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class PluginState:
|
|
"""Runtime state of a plugin."""
|
|
name: str
|
|
version: str
|
|
enabled: bool = True
|
|
load_order: int = 0
|
|
error_count: int = 0
|
|
last_error: Optional[str] = None
|
|
extraction_count: int = 0
|
|
avg_confidence: float = 0.0
|
|
|
|
|
|
class PluginRegistry:
|
|
"""
|
|
Runtime registry for plugin state and configuration.
|
|
|
|
Persists plugin configuration and tracks runtime statistics.
|
|
"""
|
|
|
|
def __init__(self, config_path: Optional[Path] = None):
|
|
"""
|
|
Initialize registry.
|
|
|
|
Args:
|
|
config_path: Path to persist configuration (optional)
|
|
"""
|
|
self.config_path = config_path
|
|
self._plugin_states: Dict[str, PluginState] = {}
|
|
self._load_order_counter = 0
|
|
|
|
if config_path and config_path.exists():
|
|
self._load_config()
|
|
|
|
def register_plugin(
|
|
self,
|
|
plugin: MetadataExtractorPlugin,
|
|
enabled: bool = True
|
|
) -> None:
|
|
"""Register a plugin with state tracking."""
|
|
name = plugin.name
|
|
|
|
if name in self._plugin_states:
|
|
state = self._plugin_states[name]
|
|
state.version = plugin.metadata.version
|
|
else:
|
|
state = PluginState(
|
|
name=name,
|
|
version=plugin.metadata.version,
|
|
enabled=enabled,
|
|
load_order=self._load_order_counter
|
|
)
|
|
self._load_order_counter += 1
|
|
self._plugin_states[name] = state
|
|
|
|
def unregister_plugin(self, name: str) -> None:
|
|
"""Unregister a plugin."""
|
|
if name in self._plugin_states:
|
|
del self._plugin_states[name]
|
|
|
|
def enable_plugin(self, name: str) -> bool:
|
|
"""Enable a plugin."""
|
|
if name not in self._plugin_states:
|
|
return False
|
|
self._plugin_states[name].enabled = True
|
|
self._save_config()
|
|
return True
|
|
|
|
def disable_plugin(self, name: str) -> bool:
|
|
"""Disable a plugin."""
|
|
if name not in self._plugin_states:
|
|
return False
|
|
self._plugin_states[name].enabled = False
|
|
self._save_config()
|
|
return True
|
|
|
|
def is_enabled(self, name: str) -> bool:
|
|
"""Check if plugin is enabled."""
|
|
if name not in self._plugin_states:
|
|
return False
|
|
return self._plugin_states[name].enabled
|
|
|
|
def record_error(self, name: str, error: str) -> None:
|
|
"""Record an error for a plugin."""
|
|
if name not in self._plugin_states:
|
|
return
|
|
state = self._plugin_states[name]
|
|
state.error_count += 1
|
|
state.last_error = error
|
|
|
|
def record_extraction(
|
|
self,
|
|
name: str,
|
|
confidence: float = 1.0
|
|
) -> None:
|
|
"""Record a successful extraction."""
|
|
if name not in self._plugin_states:
|
|
return
|
|
state = self._plugin_states[name]
|
|
state.extraction_count += 1
|
|
# Running average of confidence
|
|
total = state.extraction_count
|
|
state.avg_confidence = (
|
|
(state.avg_confidence * (total - 1) + confidence) / total
|
|
)
|
|
|
|
def get_state(self, name: str) -> Optional[PluginState]:
|
|
"""Get state for a plugin."""
|
|
return self._plugin_states.get(name)
|
|
|
|
def get_all_states(self) -> List[PluginState]:
|
|
"""Get all plugin states."""
|
|
return list(self._plugin_states.values())
|
|
|
|
def get_enabled_plugins(self) -> List[PluginState]:
|
|
"""Get states for enabled plugins only."""
|
|
return [s for s in self._plugin_states.values() if s.enabled]
|
|
|
|
def _load_config(self) -> None:
|
|
"""Load configuration from disk."""
|
|
try:
|
|
data = json.loads(self.config_path.read_text())
|
|
for name, state_data in data.get("plugins", {}).items():
|
|
self._plugin_states[name] = PluginState(**state_data)
|
|
logger.info(f"Loaded plugin config from {self.config_path}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to load plugin config: {e}")
|
|
|
|
def _save_config(self) -> None:
|
|
"""Save configuration to disk."""
|
|
if not self.config_path:
|
|
return
|
|
|
|
try:
|
|
data = {
|
|
"plugins": {
|
|
name: asdict(state)
|
|
for name, state in self._plugin_states.items()
|
|
}
|
|
}
|
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self.config_path.write_text(json.dumps(data, indent=2))
|
|
except Exception as e:
|
|
logger.warning(f"Failed to save plugin config: {e}")
|
|
|
|
def get_stats(self) -> Dict[str, Any]:
|
|
"""Get registry statistics."""
|
|
states = self.get_all_states()
|
|
return {
|
|
"total_plugins": len(states),
|
|
"enabled_plugins": len(self.get_enabled_plugins()),
|
|
"total_extractions": sum(s.extraction_count for s in states),
|
|
"total_errors": sum(s.error_count for s in states),
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### **Phase 2: Integration** (Week 2)
|
|
|
|
#### **2.1 Modify MetadataService**
|
|
|
|
**File:** `mjr_am_backend/features/metadata/service.py`
|
|
|
|
```python
|
|
"""Metadata extraction service - Modified for plugin support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Optional
|
|
import logging
|
|
|
|
from .plugin_system.loader import PluginLoader
|
|
from .plugin_system.registry import PluginRegistry
|
|
from .extractors import extract_png_metadata, extract_webp_metadata
|
|
from ...config import INDEX_DIR_PATH
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MetadataService:
|
|
"""
|
|
Metadata extraction service with plugin support.
|
|
|
|
Orchestrates extraction using both built-in extractors
|
|
and plugin-based extractors.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
exiftool,
|
|
ffprobe,
|
|
settings,
|
|
plugin_dirs: Optional[list] = None
|
|
):
|
|
self.exiftool = exiftool
|
|
self.ffprobe = ffprobe
|
|
self.settings = settings
|
|
|
|
# Initialize plugin system
|
|
plugin_config_path = INDEX_DIR_PATH / "plugin_config.json"
|
|
self.plugin_registry = PluginRegistry(plugin_config_path)
|
|
self.plugin_loader = PluginLoader(
|
|
plugin_dirs=plugin_dirs,
|
|
auto_discover=True
|
|
)
|
|
|
|
logger.info(
|
|
f"MetadataService initialized with "
|
|
f"{len(self.plugin_loader.all_extractors)} plugin extractors"
|
|
)
|
|
|
|
async def extract_metadata(
|
|
self,
|
|
filepath: str,
|
|
file_type: str
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Extract metadata from file using plugin system.
|
|
|
|
Priority:
|
|
1. Plugin extractors (by priority order)
|
|
2. Built-in extractors (fallback)
|
|
|
|
Args:
|
|
filepath: Absolute path to file
|
|
file_type: File type (image, video, audio, model3d)
|
|
|
|
Returns:
|
|
Dict with extracted metadata
|
|
"""
|
|
filepath = str(filepath).strip()
|
|
|
|
# 1. Try plugin extractors first (by priority)
|
|
plugin_result = await self._extract_with_plugins(filepath)
|
|
if plugin_result and plugin_result.get("success"):
|
|
logger.debug(
|
|
f"Plugin extraction successful for {filepath} "
|
|
f"(extractor: {plugin_result.get('extractor')})"
|
|
)
|
|
return plugin_result.get("data", {})
|
|
|
|
# 2. Fallback to built-in extractors
|
|
logger.debug(f"Falling back to built-in extractor for {filepath}")
|
|
return await self._extract_with_builtin(filepath, file_type)
|
|
|
|
async def _extract_with_plugins(
|
|
self,
|
|
filepath: str
|
|
) -> Optional[Dict[str, Any]]:
|
|
"""Try extraction with plugin extractors."""
|
|
extractor = self.plugin_loader.get_extractor(filepath)
|
|
|
|
if not extractor:
|
|
return None
|
|
|
|
plugin_name = extractor.name
|
|
|
|
# Check if plugin is enabled
|
|
if not self.plugin_registry.is_enabled(plugin_name):
|
|
logger.debug(f"Plugin {plugin_name} is disabled, skipping")
|
|
return None
|
|
|
|
try:
|
|
# Pre-extraction hook
|
|
if not await extractor.pre_extract(filepath):
|
|
logger.debug(f"Plugin {plugin_name} pre_extract returned False")
|
|
return None
|
|
|
|
# Extract
|
|
result = await extractor.extract(filepath)
|
|
|
|
if result.success:
|
|
# Record success
|
|
self.plugin_registry.record_extraction(
|
|
plugin_name,
|
|
result.confidence
|
|
)
|
|
|
|
# Post-extraction hook
|
|
result = await extractor.post_extract(filepath, result)
|
|
|
|
return {
|
|
"success": True,
|
|
"data": result.data,
|
|
"extractor": plugin_name,
|
|
"confidence": result.confidence,
|
|
}
|
|
else:
|
|
# Record error
|
|
self.plugin_registry.record_error(
|
|
plugin_name,
|
|
result.error or "Unknown error"
|
|
)
|
|
logger.warning(
|
|
f"Plugin {plugin_name} extraction failed: {result.error}"
|
|
)
|
|
return None
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Plugin {plugin_name} raised exception: {e}")
|
|
self.plugin_registry.record_error(plugin_name, str(e))
|
|
return None
|
|
|
|
async def _extract_with_builtin(
|
|
self,
|
|
filepath: str,
|
|
file_type: str
|
|
) -> Dict[str, Any]:
|
|
"""Extract using built-in extractors."""
|
|
try:
|
|
ext = Path(filepath).suffix.lower()
|
|
|
|
if ext in ['.png']:
|
|
data = await extract_png_metadata(
|
|
filepath, self.exiftool, self.ffprobe
|
|
)
|
|
elif ext in ['.webp', '.gif']:
|
|
data = await extract_webp_metadata(
|
|
filepath, self.exiftool, self.ffprobe
|
|
)
|
|
elif file_type == 'video':
|
|
data = await extract_video_metadata(
|
|
filepath, self.ffprobe
|
|
)
|
|
elif file_type == 'audio':
|
|
data = await extract_audio_metadata(
|
|
filepath, self.ffprobe
|
|
)
|
|
elif file_type == 'model3d':
|
|
data = await extract_3d_model_metadata(filepath)
|
|
else:
|
|
data = {"success": False, "error": "No extractor available"}
|
|
|
|
return data
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Built-in extraction failed: {e}")
|
|
return {
|
|
"success": False,
|
|
"error": str(e),
|
|
"extractor": "builtin"
|
|
}
|
|
|
|
def get_plugin_stats(self) -> Dict[str, Any]:
|
|
"""Get plugin system statistics."""
|
|
return {
|
|
"loader": self.plugin_loader.get_stats(),
|
|
"registry": self.plugin_registry.get_stats(),
|
|
}
|
|
|
|
def enable_plugin(self, name: str) -> bool:
|
|
"""Enable a plugin by name."""
|
|
return self.plugin_registry.enable_plugin(name)
|
|
|
|
def disable_plugin(self, name: str) -> bool:
|
|
"""Disable a plugin by name."""
|
|
return self.plugin_registry.disable_plugin(name)
|
|
|
|
def list_plugins(self) -> list:
|
|
"""List all registered plugins with state."""
|
|
plugins = []
|
|
for extractor in self.plugin_loader.all_extractors:
|
|
state = self.plugin_registry.get_state(extractor.name)
|
|
plugins.append({
|
|
"name": extractor.name,
|
|
"version": extractor.metadata.version,
|
|
"enabled": state.enabled if state else True,
|
|
"priority": extractor.priority,
|
|
"extensions": extractor.supported_extensions,
|
|
"extractions": state.extraction_count if state else 0,
|
|
"errors": state.error_count if state else 0,
|
|
})
|
|
return plugins
|
|
```
|
|
|
|
#### **2.2 Add Plugin Management Routes**
|
|
|
|
**File:** `mjr_am_backend/routes/handlers/plugins.py`
|
|
|
|
```python
|
|
"""Plugin management API routes."""
|
|
|
|
from aiohttp import web
|
|
from ...shared import Result, get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
def register_plugin_routes(routes: web.RouteTableDef) -> None:
|
|
"""Register plugin management routes."""
|
|
|
|
@routes.get("/mjr/am/plugins/list")
|
|
async def list_plugins(request: web.Request) -> web.Response:
|
|
"""List all registered plugins."""
|
|
try:
|
|
services = request.app["services"]
|
|
metadata_service = services.get("metadata")
|
|
|
|
if not metadata_service:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "Metadata service not available"
|
|
})
|
|
|
|
plugins = metadata_service.list_plugins()
|
|
stats = metadata_service.get_plugin_stats()
|
|
|
|
return web.json_response({
|
|
"ok": True,
|
|
"data": {
|
|
"plugins": plugins,
|
|
"stats": stats
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.exception("Failed to list plugins")
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": str(e)
|
|
}, status=500)
|
|
|
|
@routes.post("/mjr/am/plugins/{name}/enable")
|
|
async def enable_plugin(request: web.Request) -> web.Response:
|
|
"""Enable a plugin."""
|
|
try:
|
|
name = request.match_info["name"]
|
|
services = request.app["services"]
|
|
metadata_service = services.get("metadata")
|
|
|
|
if not metadata_service:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "Metadata service not available"
|
|
})
|
|
|
|
success = metadata_service.enable_plugin(name)
|
|
|
|
return web.json_response({
|
|
"ok": success,
|
|
"data": {"enabled": success}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Failed to enable plugin {name}")
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": str(e)
|
|
}, status=500)
|
|
|
|
@routes.post("/mjr/am/plugins/{name}/disable")
|
|
async def disable_plugin(request: web.Request) -> web.Response:
|
|
"""Disable a plugin."""
|
|
try:
|
|
name = request.match_info["name"]
|
|
services = request.app["services"]
|
|
metadata_service = services.get("metadata")
|
|
|
|
if not metadata_service:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "Metadata service not available"
|
|
})
|
|
|
|
success = metadata_service.disable_plugin(name)
|
|
|
|
return web.json_response({
|
|
"ok": success,
|
|
"data": {"enabled": not success}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Failed to disable plugin {name}")
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": str(e)
|
|
}, status=500)
|
|
|
|
@routes.post("/mjr/am/plugins/reload")
|
|
async def reload_plugins(request: web.Request) -> web.Response:
|
|
"""Reload all plugins (hot-reload)."""
|
|
try:
|
|
services = request.app["services"]
|
|
metadata_service = services.get("metadata")
|
|
|
|
if not metadata_service:
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": "Metadata service not available"
|
|
})
|
|
|
|
# Rediscover plugins
|
|
count = metadata_service.plugin_loader.discover_plugins()
|
|
|
|
return web.json_response({
|
|
"ok": True,
|
|
"data": {
|
|
"reloaded": count,
|
|
"message": f"Reloaded {count} plugins"
|
|
}
|
|
})
|
|
|
|
except Exception as e:
|
|
logger.exception("Failed to reload plugins")
|
|
return web.json_response({
|
|
"ok": False,
|
|
"error": str(e)
|
|
}, status=500)
|
|
```
|
|
|
|
---
|
|
|
|
### **Phase 3: Example Plugins** (Week 3)
|
|
|
|
#### **3.1 WanVideo Extractor Plugin**
|
|
|
|
**File:** `plugins/examples/wanvideo_extractor.py`
|
|
|
|
```python
|
|
"""
|
|
WanVideo Metadata Extractor Plugin
|
|
|
|
Extracts WanVideo-specific metadata from generated images.
|
|
WanVideo stores custom parameters in PNG text chunks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
import json
|
|
import logging
|
|
|
|
# Import from plugin system
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
MetadataExtractorPlugin,
|
|
ExtractionResult,
|
|
ExtractorMetadata,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WanVideoExtractor(MetadataExtractorPlugin):
|
|
"""Extractor for WanVideo custom node metadata."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "wanvideo_extractor"
|
|
|
|
@property
|
|
def supported_extensions(self) -> list[str]:
|
|
return ['.png', '.webp']
|
|
|
|
@property
|
|
def priority(self) -> int:
|
|
return 100 # High priority - runs before generic extractors
|
|
|
|
@property
|
|
def metadata(self) -> ExtractorMetadata:
|
|
return ExtractorMetadata(
|
|
name=self.name,
|
|
version="1.0.0",
|
|
author="Majoor Team",
|
|
description="Extract WanVideo metadata from PNG/WebP files",
|
|
homepage="https://github.com/MajoorWaldi/ComfyUI-Majoor-AssetsManager",
|
|
license="MIT"
|
|
)
|
|
|
|
async def extract(self, filepath: str) -> ExtractionResult:
|
|
"""Extract WanVideo metadata."""
|
|
try:
|
|
from PIL import Image
|
|
|
|
result_data = {
|
|
"prompt": None,
|
|
"negative_prompt": None,
|
|
"seed": None,
|
|
"steps": None,
|
|
"sampler": None,
|
|
"cfg": None,
|
|
"models": [],
|
|
"loras": [],
|
|
"custom_data": {
|
|
"wan_version": None,
|
|
"motion_bucket_id": None,
|
|
"fps": None,
|
|
"aug_level": None,
|
|
"source_type": None,
|
|
}
|
|
}
|
|
|
|
img = None
|
|
try:
|
|
img = Image.open(filepath)
|
|
|
|
# Check for WanVideo-specific PNG chunks
|
|
wan_data = None
|
|
|
|
if "wanv2" in img.info:
|
|
wan_data = json.loads(img.info["wanv2"])
|
|
elif "wanvideo" in img.info:
|
|
wan_data = json.loads(img.info["wanvideo"])
|
|
|
|
if wan_data:
|
|
result_data["custom_data"].update({
|
|
"wan_version": wan_data.get("version"),
|
|
"motion_bucket_id": wan_data.get("motion_bucket_id"),
|
|
"fps": wan_data.get("fps"),
|
|
"aug_level": wan_data.get("aug_level"),
|
|
"source_type": wan_data.get("source_type"),
|
|
})
|
|
|
|
# Also extract standard ComfyUI metadata
|
|
if "parameters" in img.info:
|
|
param_string = img.info["parameters"]
|
|
parsed = self._parse_parameters(param_string)
|
|
result_data.update(parsed)
|
|
|
|
img.close()
|
|
|
|
except Exception as e:
|
|
if img:
|
|
img.close()
|
|
raise
|
|
|
|
return self._create_success_result(result_data, confidence=0.95)
|
|
|
|
except Exception as e:
|
|
logger.exception(f"WanVideo extraction failed for {filepath}")
|
|
return self._create_error_result(str(e))
|
|
|
|
def _parse_parameters(self, param_string: str) -> Dict[str, Any]:
|
|
"""Parse ComfyUI parameter string."""
|
|
# Implementation for parsing standard ComfyUI metadata
|
|
# This is simplified - use existing parser from extractors.py
|
|
result = {}
|
|
|
|
try:
|
|
lines = param_string.split('\n')
|
|
for line in lines:
|
|
if ':' in line:
|
|
key, value = line.split(':', 1)
|
|
key = key.strip().lower()
|
|
value = value.strip()
|
|
|
|
if key == 'seed':
|
|
result['seed'] = int(value)
|
|
elif key == 'steps':
|
|
result['steps'] = int(value)
|
|
elif key == 'cfg':
|
|
result['cfg'] = float(value)
|
|
elif key == 'sampler':
|
|
result['sampler'] = value
|
|
except Exception:
|
|
pass
|
|
|
|
return result
|
|
```
|
|
|
|
#### **3.2 rgthree Extractor Plugin**
|
|
|
|
**File:** `plugins/examples/rgthree_extractor.py`
|
|
|
|
```python
|
|
"""
|
|
rgthree Metadata Extractor Plugin
|
|
|
|
Extracts rgthree custom node metadata including comparison images
|
|
and advanced workflow data.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict
|
|
import json
|
|
import logging
|
|
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
MetadataExtractorPlugin,
|
|
ExtractionResult,
|
|
ExtractorMetadata,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class RgthreeExtractor(MetadataExtractorPlugin):
|
|
"""Extractor for rgthree custom node metadata."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "rgthree_extractor"
|
|
|
|
@property
|
|
def supported_extensions(self) -> list[str]:
|
|
return ['.png', '.json']
|
|
|
|
@property
|
|
def priority(self) -> int:
|
|
return 50
|
|
|
|
@property
|
|
def metadata(self) -> ExtractorMetadata:
|
|
return ExtractorMetadata(
|
|
name=self.name,
|
|
version="1.0.0",
|
|
author="Majoor Team",
|
|
description="Extract rgthree node metadata",
|
|
license="MIT"
|
|
)
|
|
|
|
async def extract(self, filepath: str) -> ExtractionResult:
|
|
"""Extract rgthree metadata."""
|
|
try:
|
|
result_data = {
|
|
"custom_data": {
|
|
"rgthree_nodes": [],
|
|
"comparison_images": [],
|
|
"context_mappings": [],
|
|
}
|
|
}
|
|
|
|
# Try to extract from PNG metadata
|
|
if filepath.lower().endswith('.png'):
|
|
result_data.update(
|
|
await self._extract_from_png(filepath)
|
|
)
|
|
|
|
# Try to extract from workflow JSON
|
|
elif filepath.lower().endswith('.json'):
|
|
result_data.update(
|
|
await self._extract_from_json(filepath)
|
|
)
|
|
|
|
return self._create_success_result(result_data, confidence=0.85)
|
|
|
|
except Exception as e:
|
|
logger.exception(f"rgthree extraction failed for {filepath}")
|
|
return self._create_error_result(str(e))
|
|
|
|
async def _extract_from_png(self, filepath: str) -> Dict[str, Any]:
|
|
"""Extract rgthree data from PNG."""
|
|
from PIL import Image
|
|
|
|
result = {"custom_data": {}}
|
|
|
|
try:
|
|
img = Image.open(filepath)
|
|
|
|
# Look for rgthree-specific metadata
|
|
if "rgthree" in img.info:
|
|
rgthree_data = json.loads(img.info["rgthree"])
|
|
result["custom_data"]["rgthree_nodes"] = rgthree_data.get("nodes", [])
|
|
|
|
img.close()
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Failed to extract rgthree PNG data: {e}")
|
|
|
|
return result
|
|
|
|
async def _extract_from_json(self, filepath: str) -> Dict[str, Any]:
|
|
"""Extract rgthree data from workflow JSON."""
|
|
result = {"custom_data": {}}
|
|
|
|
try:
|
|
with open(filepath, 'r', encoding='utf-8') as f:
|
|
workflow = json.load(f)
|
|
|
|
# Look for rgthree nodes in workflow
|
|
nodes = workflow.get("nodes", [])
|
|
rgthree_nodes = [
|
|
n for n in nodes
|
|
if n.get("type", "").startswith("rgthree.")
|
|
]
|
|
|
|
result["custom_data"]["rgthree_nodes"] = rgthree_nodes
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Failed to extract rgthree JSON data: {e}")
|
|
|
|
return result
|
|
```
|
|
|
|
---
|
|
|
|
### **Phase 4: Documentation** (Week 4)
|
|
|
|
#### **4.1 Plugin Development Guide**
|
|
|
|
**File:** `docs/PLUGIN_DEVELOPMENT_GUIDE.md`
|
|
|
|
```markdown
|
|
# Plugin Development Guide
|
|
|
|
## Quick Start
|
|
|
|
### 1. Create Plugin File
|
|
|
|
```python
|
|
# my_plugin.py
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
MetadataExtractorPlugin,
|
|
ExtractionResult,
|
|
)
|
|
|
|
class MyExtractor(MetadataExtractorPlugin):
|
|
@property
|
|
def name(self): return "my_extractor"
|
|
|
|
@property
|
|
def supported_extensions(self): return ['.png']
|
|
|
|
@property
|
|
def priority(self): return 50
|
|
|
|
async def extract(self, filepath):
|
|
# Your extraction logic here
|
|
return self._create_success_result({"key": "value"})
|
|
```
|
|
|
|
### 2. Install Plugin
|
|
|
|
Copy to plugin directory:
|
|
```bash
|
|
cp my_plugin.py ~/.comfyui/majoor_plugins/extractors/
|
|
```
|
|
|
|
### 3. Reload Plugins
|
|
|
|
In Assets Manager UI: Settings → Plugins → Reload
|
|
|
|
Or via API:
|
|
```bash
|
|
curl -X POST http://localhost:8188/mjr/am/plugins/reload
|
|
```
|
|
|
|
## Plugin API Reference
|
|
|
|
See `base.py` for full API documentation.
|
|
|
|
## Examples
|
|
|
|
See `plugins/examples/` for complete examples.
|
|
```
|
|
|
|
---
|
|
|
|
## 🔒 Security Model
|
|
|
|
### **Plugin Validation**
|
|
|
|
```python
|
|
# mjr_am_backend/features/metadata/plugin_system/validator.py
|
|
|
|
import ast
|
|
import re
|
|
from pathlib import Path
|
|
from typing import List, Tuple
|
|
|
|
class PluginValidator:
|
|
"""Validates plugins for security issues."""
|
|
|
|
DANGEROUS_PATTERNS = [
|
|
r"__import__\s*\(\s*['\"]os['\"]\s*\)",
|
|
r"__import__\s*\(\s*['\"]subprocess['\"]\s*\)",
|
|
r"subprocess\.(call|run|Popen|check_output)",
|
|
r"os\.(system|popen|spawn|exec)",
|
|
r"eval\s*\(",
|
|
r"exec\s*\(",
|
|
r"compile\s*\(",
|
|
r"__builtins__",
|
|
r"importlib\.(reload|import_module)",
|
|
]
|
|
|
|
@classmethod
|
|
def validate(cls, plugin_path: Path) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Validate a plugin file.
|
|
|
|
Returns:
|
|
(is_valid, list_of_warnings)
|
|
"""
|
|
warnings = []
|
|
|
|
try:
|
|
content = plugin_path.read_text(encoding='utf-8')
|
|
except Exception as e:
|
|
return False, [f"Cannot read file: {e}"]
|
|
|
|
# Pattern-based checks
|
|
for pattern in cls.DANGEROUS_PATTERNS:
|
|
if re.search(pattern, content):
|
|
warnings.append(f"Dangerous pattern detected: {pattern}")
|
|
|
|
# AST-based checks
|
|
try:
|
|
tree = ast.parse(content)
|
|
warnings.extend(cls._check_ast(tree))
|
|
except SyntaxError as e:
|
|
return False, [f"Syntax error: {e}"]
|
|
|
|
is_valid = len(warnings) == 0
|
|
return is_valid, warnings
|
|
|
|
@classmethod
|
|
def _check_ast(cls, tree: ast.AST) -> List[str]:
|
|
"""AST-based security checks."""
|
|
warnings = []
|
|
|
|
for node in ast.walk(tree):
|
|
# Check for dangerous imports
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
if alias.name in ['os', 'subprocess', 'ctypes']:
|
|
warnings.append(
|
|
f"Dangerous import: {alias.name}"
|
|
)
|
|
|
|
if isinstance(node, ast.ImportFrom):
|
|
if node.module in ['os', 'subprocess', 'ctypes']:
|
|
warnings.append(f"Dangerous import from: {node.module}")
|
|
|
|
return warnings
|
|
```
|
|
|
|
### **Sandboxed Execution**
|
|
|
|
```python
|
|
# mjr_am_backend/features/metadata/plugin_system/sandbox.py
|
|
|
|
import asyncio
|
|
import functools
|
|
from typing import Any, Callable, TypeVar
|
|
|
|
T = TypeVar('T')
|
|
|
|
class ExecutionSandbox:
|
|
"""
|
|
Sandboxed execution for plugin methods.
|
|
|
|
Limits:
|
|
- Timeout: 30 seconds max
|
|
- Memory: 512MB max (via resource limits on Unix)
|
|
- Filesystem: Read-only access to specified paths
|
|
"""
|
|
|
|
DEFAULT_TIMEOUT = 30.0 # seconds
|
|
|
|
@classmethod
|
|
async def run_with_timeout(
|
|
cls,
|
|
func: Callable,
|
|
*args,
|
|
timeout: float = DEFAULT_TIMEOUT,
|
|
**kwargs
|
|
) -> T:
|
|
"""Run function with timeout."""
|
|
try:
|
|
loop = asyncio.get_event_loop()
|
|
return await asyncio.wait_for(
|
|
loop.run_in_executor(None, functools.partial(func, *args, **kwargs)),
|
|
timeout=timeout
|
|
)
|
|
except asyncio.TimeoutError:
|
|
raise TimeoutError(f"Plugin execution timed out after {timeout}s")
|
|
|
|
@classmethod
|
|
def restrict_filesystem(
|
|
cls,
|
|
allowed_paths: list
|
|
) -> Callable:
|
|
"""
|
|
Decorator to restrict filesystem access.
|
|
|
|
Usage:
|
|
@restrict_filesystem(['/allowed/path'])
|
|
def extract(self, filepath):
|
|
...
|
|
"""
|
|
def decorator(func: Callable) -> Callable:
|
|
@functools.wraps(func)
|
|
def wrapper(*args, **kwargs):
|
|
# Validate all path arguments
|
|
for arg in args:
|
|
if isinstance(arg, str):
|
|
cls._validate_path(arg, allowed_paths)
|
|
for value in kwargs.values():
|
|
if isinstance(value, str):
|
|
cls._validate_path(value, allowed_paths)
|
|
|
|
return func(*args, **kwargs)
|
|
return wrapper
|
|
return decorator
|
|
|
|
@staticmethod
|
|
def _validate_path(path: str, allowed_paths: list) -> None:
|
|
"""Validate path is within allowed directories."""
|
|
from pathlib import Path
|
|
|
|
resolved = Path(path).resolve()
|
|
|
|
for allowed in allowed_paths:
|
|
try:
|
|
resolved.relative_to(Path(allowed).resolve())
|
|
return # Path is within allowed directory
|
|
except ValueError:
|
|
continue
|
|
|
|
raise PermissionError(
|
|
f"Access denied: {path} is outside allowed directories"
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 🧪 Testing Strategy
|
|
|
|
### **Unit Tests**
|
|
|
|
```python
|
|
# tests/plugins/test_plugin_loader.py
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
from mjr_am_backend.features.metadata.plugin_system.loader import PluginLoader
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
MetadataExtractorPlugin,
|
|
ExtractionResult,
|
|
)
|
|
|
|
|
|
class MockExtractor(MetadataExtractorPlugin):
|
|
@property
|
|
def name(self): return "mock_extractor"
|
|
|
|
@property
|
|
def supported_extensions(self): return ['.png']
|
|
|
|
@property
|
|
def priority(self): return 50
|
|
|
|
async def extract(self, filepath):
|
|
return self._create_success_result({"test": "data"})
|
|
|
|
|
|
class TestPluginLoader:
|
|
@pytest.fixture
|
|
def loader(self, tmp_path):
|
|
return PluginLoader([tmp_path], auto_discover=False)
|
|
|
|
def test_register_extractor(self, loader):
|
|
extractor = MockExtractor()
|
|
loader.register_extractor(extractor)
|
|
|
|
assert "mock_extractor" in loader.extractor_names
|
|
assert loader.get_extractor_by_name("mock_extractor") is extractor
|
|
|
|
def test_get_extractor_by_extension(self, loader):
|
|
loader.register_extractor(MockExtractor())
|
|
|
|
extractor = loader.get_extractor("test.png")
|
|
assert extractor is not None
|
|
assert extractor.name == "mock_extractor"
|
|
|
|
def test_priority_ordering(self, loader):
|
|
class HighPriority(MockExtractor):
|
|
@property
|
|
def priority(self): return 100
|
|
|
|
class LowPriority(MockExtractor):
|
|
@property
|
|
def priority(self): return 10
|
|
|
|
loader.register_extractor(LowPriority())
|
|
loader.register_extractor(HighPriority())
|
|
|
|
# Should get high priority first
|
|
extractor = loader.get_extractor("test.png")
|
|
assert extractor.priority == 100
|
|
|
|
|
|
# tests/plugins/test_plugin_validator.py
|
|
|
|
from mjr_am_backend.features.metadata.plugin_system.validator import (
|
|
PluginValidator,
|
|
)
|
|
|
|
|
|
class TestPluginValidator:
|
|
def test_safe_plugin(self, tmp_path):
|
|
plugin_file = tmp_path / "safe_plugin.py"
|
|
plugin_file.write_text("""
|
|
class SafeExtractor:
|
|
pass
|
|
""")
|
|
|
|
is_valid, warnings = PluginValidator.validate(plugin_file)
|
|
assert is_valid is True
|
|
assert len(warnings) == 0
|
|
|
|
def test_dangerous_import(self, tmp_path):
|
|
plugin_file = tmp_path / "dangerous.py"
|
|
plugin_file.write_text("""
|
|
import os
|
|
os.system('echo hello')
|
|
""")
|
|
|
|
is_valid, warnings = PluginValidator.validate(plugin_file)
|
|
assert "Dangerous import: os" in warnings
|
|
```
|
|
|
|
### **Integration Tests**
|
|
|
|
```python
|
|
# tests/plugins/test_plugin_integration.py
|
|
|
|
import pytest
|
|
from mjr_am_backend.features.metadata.service import MetadataService
|
|
|
|
|
|
class TestPluginIntegration:
|
|
@pytest.fixture
|
|
def metadata_service(self, tmp_path):
|
|
plugin_dir = tmp_path / "plugins"
|
|
plugin_dir.mkdir()
|
|
|
|
# Create test plugin
|
|
plugin_file = plugin_dir / "test_extractor.py"
|
|
plugin_file.write_text("""
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
MetadataExtractorPlugin,
|
|
ExtractionResult,
|
|
)
|
|
|
|
class TestExtractor(MetadataExtractorPlugin):
|
|
@property
|
|
def name(self): return "test_extractor"
|
|
@property
|
|
def supported_extensions(self): return ['.png']
|
|
@property
|
|
def priority(self): return 100
|
|
async def extract(self, filepath):
|
|
return self._create_success_result({"from_plugin": True})
|
|
""")
|
|
|
|
return MetadataService(
|
|
exiftool=None,
|
|
ffprobe=None,
|
|
settings=None,
|
|
plugin_dirs=[plugin_dir]
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_plugin_extraction(self, metadata_service, tmp_path):
|
|
# Create test file
|
|
test_file = tmp_path / "test.png"
|
|
test_file.write_bytes(b"fake png")
|
|
|
|
result = await metadata_service.extract_metadata(
|
|
str(test_file),
|
|
"image"
|
|
)
|
|
|
|
assert result.get("from_plugin") is True
|
|
```
|
|
|
|
---
|
|
|
|
## 📦 Migration Guide
|
|
|
|
### **For Existing Code**
|
|
|
|
#### **Step 1: Update MetadataService Initialization**
|
|
|
|
**Before:**
|
|
```python
|
|
metadata_service = MetadataService(exiftool, ffprobe, settings)
|
|
```
|
|
|
|
**After:**
|
|
```python
|
|
metadata_service = MetadataService(
|
|
exiftool,
|
|
ffprobe,
|
|
settings,
|
|
plugin_dirs=None # Uses defaults
|
|
)
|
|
```
|
|
|
|
#### **Step 2: Update Extractor Calls**
|
|
|
|
**Before:**
|
|
```python
|
|
from .extractor_registry import get_extractor
|
|
|
|
extractor = get_extractor("png")
|
|
result = await extractor(filepath)
|
|
```
|
|
|
|
**After:**
|
|
```python
|
|
# No change needed - service handles plugin routing
|
|
result = await metadata_service.extract_metadata(filepath, "image")
|
|
```
|
|
|
|
#### **Step 3: Add Plugin Directory to .gitignore**
|
|
|
|
```gitignore
|
|
# Plugin directories
|
|
plugins/*.pyc
|
|
plugins/__pycache__/
|
|
~/.comfyui/majoor_plugins/
|
|
output/_mjr_plugins/
|
|
```
|
|
|
|
### **For Plugin Developers**
|
|
|
|
#### **Migrating from Custom Forks**
|
|
|
|
If you've created custom extractors by modifying the core codebase:
|
|
|
|
1. **Extract your logic** into a plugin class
|
|
2. **Move to plugin directory** (`~/.comfyui/majoor_plugins/extractors/`)
|
|
3. **Remove core modifications**
|
|
4. **Test with plugin system**
|
|
|
|
**Example Migration:**
|
|
|
|
**Before (Core Modification):**
|
|
```python
|
|
# mjr_am_backend/features/metadata/extractors.py
|
|
async def extract_my_custom_metadata(filepath):
|
|
# Your custom logic
|
|
pass
|
|
```
|
|
|
|
**After (Plugin):**
|
|
```python
|
|
# ~/.comfyui/majoor_plugins/extractors/my_extractor.py
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
MetadataExtractorPlugin,
|
|
ExtractionResult,
|
|
)
|
|
|
|
class MyExtractor(MetadataExtractorPlugin):
|
|
@property
|
|
def name(self): return "my_extractor"
|
|
@property
|
|
def supported_extensions(self): return ['.png']
|
|
@property
|
|
def priority(self): return 100
|
|
|
|
async def extract(self, filepath):
|
|
# Your custom logic here
|
|
return self._create_success_result({...})
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Performance Considerations
|
|
|
|
### **Plugin Loading**
|
|
|
|
- **Cold Start:** 100-500ms for 10 plugins
|
|
- **Hot Reload:** 50-200ms
|
|
- **Memory:** ~5-10MB per plugin
|
|
|
|
### **Extraction Overhead**
|
|
|
|
- **Plugin Routing:** <1ms per file
|
|
- **Priority Sorting:** Cached, negligible
|
|
- **Fallback Chain:** Adds 2-5ms if plugins fail
|
|
|
|
### **Optimization Strategies**
|
|
|
|
```python
|
|
# 1. Lazy loading - plugins loaded on first use
|
|
class LazyPluginLoader:
|
|
def __init__(self):
|
|
self._cache = {}
|
|
|
|
def get_extractor(self, filepath):
|
|
ext = Path(filepath).suffix
|
|
if ext not in self._cache:
|
|
self._cache[ext] = self._load_for_extension(ext)
|
|
return self._cache[ext]
|
|
|
|
# 2. Result caching
|
|
from functools import lru_cache
|
|
|
|
class CachedExtractor:
|
|
@lru_cache(maxsize=1000)
|
|
def extract_cached(self, filepath_hash):
|
|
...
|
|
|
|
# 3. Parallel extraction for batch operations
|
|
async def extract_batch(filepaths, max_concurrency=4):
|
|
semaphore = asyncio.Semaphore(max_concurrency)
|
|
|
|
async def extract_one(fp):
|
|
async with semaphore:
|
|
return await extractor.extract(fp)
|
|
|
|
return await asyncio.gather(*[extract_one(fp) for fp in filepaths])
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 Success Metrics
|
|
|
|
| Metric | Target | Measurement |
|
|
|--------|--------|-------------|
|
|
| Plugin Load Time | <500ms | Startup logs |
|
|
| Extraction Success Rate | >95% | Plugin registry stats |
|
|
| Memory Overhead | <50MB | Process monitoring |
|
|
| Plugin Compatibility | 100% | Test suite |
|
|
| Security Violations | 0 | Validator logs |
|
|
|
|
---
|
|
|
|
## 📝 Appendices
|
|
|
|
### **A. Plugin Template**
|
|
|
|
```python
|
|
"""
|
|
[Plugin Name] Extractor
|
|
|
|
[Description]
|
|
"""
|
|
|
|
from mjr_am_backend.features.metadata.plugin_system.base import (
|
|
MetadataExtractorPlugin,
|
|
ExtractionResult,
|
|
ExtractorMetadata,
|
|
)
|
|
|
|
|
|
class [PluginName]Extractor(MetadataExtractorPlugin):
|
|
@property
|
|
def name(self) -> str:
|
|
return "[plugin_name]_extractor"
|
|
|
|
@property
|
|
def supported_extensions(self) -> list[str]:
|
|
return ['.png', '.webp']
|
|
|
|
@property
|
|
def priority(self) -> int:
|
|
return 50
|
|
|
|
@property
|
|
def metadata(self) -> ExtractorMetadata:
|
|
return ExtractorMetadata(
|
|
name=self.name,
|
|
version="1.0.0",
|
|
author="Your Name",
|
|
description="Your description",
|
|
license="MIT"
|
|
)
|
|
|
|
async def extract(self, filepath: str) -> ExtractionResult:
|
|
try:
|
|
# Your extraction logic
|
|
data = {}
|
|
return self._create_success_result(data)
|
|
except Exception as e:
|
|
return self._create_error_result(str(e))
|
|
```
|
|
|
|
### **B. Environment Variables**
|
|
|
|
```bash
|
|
# Plugin directories (colon-separated on Unix, semicolon on Windows)
|
|
MJR_PLUGIN_DIRS=~/.comfyui/majoor_plugins:/opt/majoor_plugins
|
|
|
|
# Plugin security
|
|
MJR_PLUGIN_VALIDATION=strict # strict, permissive, disabled
|
|
MJR_PLUGIN_TIMEOUT=30 # seconds
|
|
|
|
# Plugin logging
|
|
MJR_PLUGIN_LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ?
|
|
```
|
|
|
|
### **C. Troubleshooting**
|
|
|
|
| Issue | Solution |
|
|
|-------|----------|
|
|
| Plugin not loading | Check logs for validation errors |
|
|
| Extraction fails | Verify file path permissions |
|
|
| Slow performance | Check plugin timeout settings |
|
|
| Memory leaks | Implement `cleanup()` method |
|
|
|
|
---
|
|
|
|
**Document End**
|