767 lines
22 KiB
Markdown
767 lines
22 KiB
Markdown
# Metadata System Integration Guide (PREVIEW)
|
|
|
|
## 1. Introduction
|
|
|
|
This guide explains how to integrate your custom nodes with the new metadata system. The system allows nodes to read and write metadata to multiple storage formats while ensuring proper data structure and compatibility.
|
|
|
|
> **Note**: This is a preview guide and may evolve as we finalize testing of the system.
|
|
|
|
## 2. System Overview
|
|
|
|
The metadata system uses a service-based architecture to provide a unified interface for handling metadata:
|
|
|
|
- **MetadataService**: Central façade that coordinates operations across different handlers
|
|
- **Handlers**: Specialized components for different storage formats (embedded, XMP, text, database)
|
|
- **Utilities**: Namespace registration, format detection, error handling, etc.
|
|
|
|
## 3. Integration Steps
|
|
|
|
### 3.1 Basic Integration
|
|
|
|
To integrate your node with the metadata system:
|
|
|
|
```python
|
|
# 1. Import the service
|
|
from eric_metadata.service import MetadataService
|
|
|
|
class MyAnalysisNode:
|
|
"""My analysis node with metadata support"""
|
|
|
|
def __init__(self):
|
|
# Initialize the metadata service
|
|
self.metadata_service = MetadataService(debug=False)
|
|
|
|
def process(self, input_image, input_path):
|
|
"""Process input image and update metadata
|
|
|
|
Args:
|
|
input_image: Image to process
|
|
input_path: Path to the image file
|
|
"""
|
|
# Use the metadata service here
|
|
# ...
|
|
```
|
|
|
|
### 3.2 Using the Resource Identifier
|
|
|
|
The resource identifier is critically important for XMP sidecar files. It ensures all nodes use the same identifier for the same file:
|
|
|
|
```python
|
|
# Always set the resource identifier before writing metadata
|
|
filename = os.path.basename(input_filepath)
|
|
resource_uri = f"file:///{filename}"
|
|
self.metadata_service.set_resource_identifier(resource_uri)
|
|
```
|
|
|
|
### 3.3 Reading Existing Metadata
|
|
|
|
To read metadata from an image:
|
|
|
|
```python
|
|
# Read from preferred source with fallback to others
|
|
metadata = self.metadata_service.read_metadata(input_filepath, source='xmp', fallback=True)
|
|
|
|
# Access specific sections
|
|
if 'analysis' in metadata and 'blur' in metadata['analysis']:
|
|
blur_score = metadata['analysis']['blur'].get('score', 0.0)
|
|
```
|
|
|
|
### 3.4 Merging Metadata
|
|
|
|
The system automatically merges new metadata with existing data. If you need to merge manually:
|
|
|
|
```python
|
|
# Read existing metadata
|
|
existing = self.metadata_service.read_metadata(filepath)
|
|
|
|
# Create your new metadata
|
|
new_metadata = {'analysis': {'my_feature': {'score': 0.75}}}
|
|
|
|
# Merge them (this happens automatically in write_metadata)
|
|
merged = self.metadata_service.merge_metadata(filepath, new_metadata)
|
|
```
|
|
|
|
## 4. Metadata Structure Guidelines
|
|
|
|
### 4.1 Top-Level Organization
|
|
|
|
```python
|
|
metadata = {
|
|
'basic': {}, # Title, description, keywords, etc.
|
|
'regions': {} # Face regions, subject areas, etc.
|
|
}
|
|
```
|
|
|
|
### 4.2 Basic Metadata
|
|
|
|
```python
|
|
basic_data = {
|
|
'title': 'My Image Title',
|
|
'rating': 4 # 0-5 scale
|
|
}
|
|
metadata['basic'] = basic_data
|
|
```
|
|
|
|
### 4.3 Analysis Data
|
|
|
|
For measurements and scores:
|
|
|
|
```python
|
|
analysis_data = {
|
|
'technical': {
|
|
'blur': {
|
|
'score': 0.85,
|
|
'higher_better': False,
|
|
'timestamp': '2023-12-01T12:34:56'
|
|
},
|
|
'noise': {
|
|
'score': 0.12,
|
|
'higher_better': False
|
|
}
|
|
}
|
|
}
|
|
metadata['analysis'] = analysis_data
|
|
```
|
|
|
|
### 4.4 AI Generation Data
|
|
|
|
```python
|
|
ai_data = {
|
|
'generation': {
|
|
'model': 'sd_xl_base_1.0',
|
|
'prompt': 'a photo of a cat',
|
|
'negative_prompt': 'blurry, low quality',
|
|
'seed': 42,
|
|
'steps': 30,
|
|
'cfg_scale': 7.5,
|
|
'sampler': 'euler_a',
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
}
|
|
metadata['ai_info'] = ai_data
|
|
```
|
|
|
|
## 5. Advanced Integration
|
|
|
|
### 5.1 Node Types
|
|
|
|
Different node types benefit from different integration patterns:
|
|
|
|
#### Analysis Nodes
|
|
|
|
```python
|
|
class ImageAnalysisNode:
|
|
def __init__(self):
|
|
self.metadata_service = MetadataService(debug=False)
|
|
|
|
def analyze(self, image_path):
|
|
# Run your analysis
|
|
sharpness_score = self._calculate_sharpness(image_path)
|
|
|
|
# Prepare metadata
|
|
metadata = {
|
|
'analysis': {
|
|
'technical': {
|
|
'sharpness': {
|
|
'score': sharpness_score,
|
|
'higher_better': True,
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# Write metadata
|
|
self.metadata_service.write_metadata(
|
|
image_path,
|
|
metadata,
|
|
targets=['embedded', 'xmp']
|
|
)
|
|
|
|
return sharpness_score
|
|
```
|
|
|
|
#### Generation Nodes
|
|
|
|
```python
|
|
class MyGenerationNode:
|
|
def __init__(self):
|
|
self.metadata_service = MetadataService(debug=False)
|
|
|
|
def generate_image(self, model_name, prompt, seed):
|
|
# Generate image (implementation specific)
|
|
image, path = self._generate_image(model_name, prompt, seed)
|
|
|
|
# Prepare generation metadata
|
|
metadata = {
|
|
'ai_info': {
|
|
'generation': {
|
|
'model': model_name,
|
|
'prompt': prompt,
|
|
'seed': seed,
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
}
|
|
}
|
|
|
|
# Write metadata
|
|
self.metadata_service.write_metadata(path, metadata)
|
|
|
|
return image, path
|
|
```
|
|
|
|
### 5.2 Format-Specific Options
|
|
|
|
Configure handler-specific behavior:
|
|
|
|
```python
|
|
# Configure specific handler options
|
|
self.metadata_service.configure_handler(
|
|
'embedded',
|
|
preserve_workflow_data=True, # PNG workflow preservation
|
|
use_exiftool=True # Force ExifTool for certain formats
|
|
)
|
|
|
|
# Configure XMP handler options
|
|
self.metadata_service.configure_handler(
|
|
'xmp',
|
|
create_sidecar_for_raw=True, # Always create XMP sidecar for RAW files
|
|
merge_strategy='newest' # How to handle conflicts
|
|
)
|
|
|
|
# Configure database handler
|
|
self.metadata_service.configure_handler(
|
|
'database',
|
|
db_path='/path/to/metadata.db',
|
|
auto_index_keywords=True
|
|
)
|
|
```
|
|
|
|
### 5.3 Working With Regions
|
|
|
|
For facial recognition or object detection nodes:
|
|
|
|
```python
|
|
def process_faces(self, image_path, faces):
|
|
# Create regions metadata
|
|
regions_metadata = {
|
|
'regions': {
|
|
'faces': [],
|
|
'summary': {
|
|
'face_count': len(faces),
|
|
'detector_type': 'my_face_detector_v1'
|
|
}
|
|
}
|
|
}
|
|
|
|
# Build face entries
|
|
for idx, face in enumerate(faces):
|
|
face_data = {
|
|
'type': 'Face',
|
|
'name': f'Face {idx+1}',
|
|
'area': {
|
|
'x': face['x'] / img_width, # Normalized coordinates (0-1)
|
|
'y': face['y'] / img_height,
|
|
'w': face['width'] / img_width,
|
|
'h': face['height'] / img_height
|
|
},
|
|
'extensions': {
|
|
'eiqa': {
|
|
'face_analysis': {
|
|
'confidence': face['confidence'],
|
|
'age': face.get('age', 0),
|
|
'gender': face.get('gender', 'unknown'),
|
|
'emotion': {
|
|
'scores': face.get('emotions', {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
regions_metadata['regions']['faces'].append(face_data)
|
|
|
|
# Write metadata
|
|
self.metadata_service.write_metadata(image_path, regions_metadata)
|
|
```
|
|
|
|
### 5.4 Example for XMP Sidecar Handler
|
|
|
|
```python
|
|
from eric_metadata.handlers.xmp import XMPSidecarHandler
|
|
|
|
def create_separate_xmp_file(self, image_path, metadata):
|
|
# Using the handler directly gives more control over specific XMP features
|
|
# that aren't exposed through the general service interface
|
|
xmp_handler = XMPSidecarHandler(debug=False)
|
|
|
|
# Set resource identifier
|
|
filename = os.path.basename(image_path)
|
|
xmp_handler.set_resource_identifier(f"file:///{filename}")
|
|
|
|
# Write XMP sidecar file
|
|
return xmp_handler.write_metadata(image_path, metadata)
|
|
```
|
|
|
|
## 6. Error Handling
|
|
|
|
### 6.1 Graceful Error Handling
|
|
|
|
```python
|
|
def safe_read_metadata(self, filepath):
|
|
try:
|
|
metadata = self.metadata_service.read_metadata(filepath, fallback=True)
|
|
return metadata
|
|
except Exception as e:
|
|
print(f"Error reading metadata: {e}")
|
|
# Return empty structure as fallback
|
|
return {'basic': {}, 'analysis': {}, 'ai_info': {}, 'regions': {}}
|
|
```
|
|
|
|
### 6.2 Format Recovery
|
|
|
|
```python
|
|
# Try embedded first, with fallback to others if it fails
|
|
metadata = self.metadata_service.read_metadata(
|
|
filepath,
|
|
source='embedded',
|
|
fallback=True,
|
|
fallback_priority=['xmp', 'database']
|
|
)
|
|
|
|
# Write with fallback options
|
|
success = self.metadata_service.write_metadata(
|
|
filepath,
|
|
metadata,
|
|
targets=['embedded'],
|
|
fallback_targets=['xmp'] # If embedded fails, try XMP
|
|
)
|
|
```
|
|
|
|
## 7. Advanced Metadata Patterns
|
|
|
|
### 7.1 Handling Batched Images
|
|
|
|
```python
|
|
def process_batch(self, image_paths):
|
|
results = {}
|
|
|
|
# Use batch operations for efficiency
|
|
metadata_map = self.metadata_service.batch_read(image_paths)
|
|
|
|
for path, metadata in metadata_map.items():
|
|
# Process each image's metadata
|
|
# ...
|
|
|
|
# Update with new data
|
|
new_metadata = self._generate_metadata(path)
|
|
merged = self.metadata_service.merge_metadata(metadata, new_metadata)
|
|
results[path] = merged
|
|
|
|
# Write back in batch
|
|
self.metadata_service.batch_write(results)
|
|
|
|
return results
|
|
```
|
|
|
|
```python
|
|
# For very large batches, consider processing in chunks to manage memory usage
|
|
max_chunk_size = 100
|
|
for i in range(0, len(large_file_list), max_chunk_size):
|
|
chunk = large_file_list[i:i + max_chunk_size]
|
|
self.metadata_service.batch_operation('read', chunk)
|
|
```
|
|
|
|
### 7.2 Using Specific Handlers Directly
|
|
|
|
```python
|
|
from eric_metadata.handlers.db import DatabaseHandler
|
|
|
|
# Direct database queries for advanced needs
|
|
def find_images_by_criteria(self, criteria):
|
|
# Create database handler directly
|
|
db_handler = DatabaseHandler(debug=False)
|
|
|
|
# Build query
|
|
query = {
|
|
'scores': [
|
|
# category, metric, operator, value
|
|
('technical', 'blur.score', '>', 0.8)
|
|
],
|
|
'keywords': ['portrait', 'outdoor'],
|
|
'classifications': [('style', 'cinematic')],
|
|
'orientation': 'landscape',
|
|
'order_by': 'i.rating DESC',
|
|
'limit': 50
|
|
}
|
|
|
|
# Execute search
|
|
results = db_handler.search_images(query)
|
|
return results
|
|
```
|
|
|
|
### 7.3 Custom Metadata Extensions
|
|
|
|
```python
|
|
# Create custom metadata sections
|
|
metadata = {
|
|
'my_extension': {
|
|
'custom_field1': 'value1',
|
|
'measurements': {
|
|
'value': 42.5,
|
|
'units': 'pixels',
|
|
'confidence': 0.95
|
|
}
|
|
}
|
|
}
|
|
|
|
# Configure the service to register your extension namespace
|
|
self.metadata_service.register_namespace(
|
|
'my_ext',
|
|
'http://example.org/myextension/'
|
|
)
|
|
|
|
# Write with your extension
|
|
self.metadata_service.write_metadata(filepath, metadata)
|
|
```
|
|
|
|
## 8. Performance Considerations
|
|
|
|
### 8.1 Resource Management
|
|
|
|
```python
|
|
# Use context manager for clean resource handling
|
|
with MetadataService() as metadata_service:
|
|
metadata = metadata_service.read_metadata(filepath)
|
|
# Process metadata...
|
|
metadata_service.write_metadata(filepath, metadata)
|
|
# Resources automatically cleaned up
|
|
```
|
|
|
|
### 8.2 Optimizing for Large Batches
|
|
|
|
```python
|
|
# For large batches, configure connection pooling
|
|
self.metadata_service.configure_handler(
|
|
'database',
|
|
connection_pooling=True,
|
|
max_connections=5
|
|
)
|
|
|
|
# Perform operations in chunks
|
|
for chunk in self._chunks(large_file_list, 100):
|
|
self.metadata_service.batch_operation('read', chunk)
|
|
```
|
|
|
|
### 8.3 Minimizing Size for Embedding
|
|
|
|
```python
|
|
def prepare_compact_metadata(self, full_metadata):
|
|
# Create compact version for embedding
|
|
compact = {}
|
|
|
|
# Include only essential fields
|
|
if 'basic' in full_metadata:
|
|
compact['basic'] = {
|
|
'title': full_metadata['basic'].get('title'),
|
|
'keywords': full_metadata['basic'].get('keywords')
|
|
}
|
|
|
|
if 'ai_info' in full_metadata:
|
|
# Just include critical generation parameters
|
|
gen_data = full_metadata['ai_info'].get('generation', {})
|
|
compact['ai_info'] = {
|
|
'generation': {
|
|
'model': gen_data.get('model'),
|
|
'seed': gen_data.get('seed'),
|
|
'sampler': gen_data.get('sampler'),
|
|
'steps': gen_data.get('steps')
|
|
}
|
|
}
|
|
|
|
return compact
|
|
```
|
|
|
|
### 8.4 Thread Safety
|
|
|
|
When multiple nodes might be accessing the metadata service concurrently:
|
|
|
|
```python
|
|
# The metadata service's write operations are internally thread-safe
|
|
# but for complex operations, consider using locks
|
|
|
|
import threading
|
|
|
|
class ThreadSafeNode:
|
|
def __init__(self):
|
|
self.metadata_service = MetadataService()
|
|
self._lock = threading.Lock()
|
|
|
|
def process_batch(self, items):
|
|
with self._lock:
|
|
# Perform a sequence of read/modify/write operations atomically
|
|
results = []
|
|
for item in items:
|
|
metadata = self.metadata_service.read_metadata(item)
|
|
# modify metadata
|
|
self.metadata_service.write_metadata(item, metadata)
|
|
results.append(metadata)
|
|
return results
|
|
```
|
|
|
|
## 9. Complete Working Example
|
|
|
|
Here's an example of a complete node integration:
|
|
|
|
```python
|
|
import os
|
|
from datetime import datetime
|
|
from eric_metadata.service import MetadataService
|
|
|
|
class ImageEnhancementNode:
|
|
"""Node that enhances images and records metadata about the process"""
|
|
|
|
def __init__(self):
|
|
self.metadata_service = MetadataService(debug=False)
|
|
self.metadata_service.configure_handler('embedded', preserve_workflow_data=True)
|
|
|
|
def enhance_image(self, input_filepath, enhancement_type, strength):
|
|
"""Enhance image and record metadata about the enhancement"""
|
|
# 1. Read existing metadata (if any)
|
|
existing_metadata = self.metadata_service.read_metadata(
|
|
input_filepath,
|
|
fallback=True
|
|
)
|
|
|
|
# 2. Process the image (implementation-specific)
|
|
output_path = self._enhance_image(input_filepath, enhancement_type, strength)
|
|
|
|
# 3. Set resource identifier (important for XMP)
|
|
filename = os.path.basename(output_path)
|
|
resource_uri = f"file:///{filename}"
|
|
self.metadata_service.set_resource_identifier(resource_uri)
|
|
|
|
# 4. Create new metadata about the enhancement
|
|
enhancement_metadata = {
|
|
'basic': {
|
|
'title': f"{enhancement_type.capitalize()} Enhanced Image",
|
|
'description': f"Enhanced using {enhancement_type} algorithm with strength {strength}",
|
|
'keywords': [enhancement_type, 'enhanced', 'processed']
|
|
},
|
|
'analysis': {
|
|
'processing': {
|
|
'enhancement_type': enhancement_type,
|
|
'strength': strength,
|
|
'quality_estimate': self._estimate_quality(output_path),
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
},
|
|
'workflow_info': {
|
|
'node_type': 'ImageEnhancement',
|
|
'version': '1.0.0',
|
|
'processing_time_ms': self._last_processing_time
|
|
}
|
|
}
|
|
|
|
# 5. Write metadata to all targets
|
|
self.metadata_service.write_metadata(
|
|
output_path,
|
|
enhancement_metadata,
|
|
targets=['embedded', 'xmp']
|
|
)
|
|
|
|
# 6. Return the enhanced image path
|
|
return output_path
|
|
|
|
def _enhance_image(self, input_path, enhancement_type, strength):
|
|
"""Implementation of actual enhancement"""
|
|
# Actual enhancement code here...
|
|
output_path = f"{os.path.splitext(input_path)[0]}_enhanced.png"
|
|
self._last_processing_time = 1250 # Example processing time in ms
|
|
return output_path
|
|
|
|
def _estimate_quality(self, image_path):
|
|
"""Estimate quality of enhanced output"""
|
|
# Quality estimation code here...
|
|
return 0.85 # Example quality score
|
|
```
|
|
|
|
## 10. Common Use Cases
|
|
|
|
### 10.1 Image Analysis Node
|
|
|
|
```python
|
|
def analyze_and_tag(self, image_path):
|
|
"""Analyze image content and add metadata tags"""
|
|
# Run image analysis (implementation-specific)
|
|
analysis_results = self._analyze_content(image_path)
|
|
|
|
# Extract tags from analysis
|
|
tags = [item['label'] for item in analysis_results
|
|
if item['confidence'] > 0.7]
|
|
|
|
# Build confidence scores dictionary
|
|
confidence_scores = {
|
|
item['label']: item['confidence']
|
|
for item in analysis_results
|
|
}
|
|
|
|
# Create metadata
|
|
metadata = {
|
|
'basic': {
|
|
'keywords': tags
|
|
},
|
|
'analysis': {
|
|
'classification': {
|
|
'tags': tags,
|
|
'confidence_scores': confidence_scores,
|
|
'detector': 'my_classifier_v1'
|
|
}
|
|
}
|
|
}
|
|
|
|
# Write metadata
|
|
self.metadata_service.write_metadata(
|
|
image_path, metadata, targets=['embedded', 'xmp', 'database']
|
|
)
|
|
|
|
# Return analysis results and tags
|
|
return analysis_results, tags
|
|
```
|
|
|
|
### 10.2 Copyright and Attribution Node
|
|
|
|
```python
|
|
def add_copyright_info(self, image_path, creator, rights_usage, copyright_text):
|
|
"""Add copyright and attribution information"""
|
|
# Format standard copyright statement
|
|
if not copyright_text:
|
|
current_year = datetime.now().year
|
|
copyright_text = f"© {current_year} {creator}. All rights reserved."
|
|
|
|
# Create metadata
|
|
metadata = {
|
|
'basic': {
|
|
'creator': creator,
|
|
'copyright': copyright_text,
|
|
'rights': rights_usage
|
|
}
|
|
}
|
|
|
|
# Add additional XMP Rights fields
|
|
metadata['xmp_rights'] = {
|
|
'Marked': True,
|
|
'UsageTerms': rights_usage or copyright_text,
|
|
'WebStatement': f"https://example.com/rights/{creator}"
|
|
}
|
|
|
|
# Write metadata with priority to embedded
|
|
self.metadata_service.write_metadata(
|
|
image_path, metadata, targets=['embedded', 'xmp']
|
|
)
|
|
|
|
return copyright_text
|
|
```
|
|
|
|
### 10.3 Workflow Capture Node
|
|
|
|
```python
|
|
from eric_metadata.utils.workflow_parser import WorkflowParser
|
|
from eric_metadata.utils.workflow_extractor import WorkflowExtractor
|
|
|
|
def capture_workflow(self, image_path, workflow_data=None):
|
|
"""Capture workflow information in metadata"""
|
|
|
|
# If no workflow data provided, try to extract from current context
|
|
if not workflow_data:
|
|
extractor = WorkflowExtractor()
|
|
workflow_data = extractor.extract_current_workflow()
|
|
|
|
# Parse workflow to extract key information
|
|
if workflow_data:
|
|
parser = WorkflowParser(workflow_data)
|
|
workflow_summary = parser.get_summary()
|
|
|
|
# Create metadata structure
|
|
metadata = {
|
|
'ai_info': {
|
|
'workflow_info': {
|
|
'node_count': workflow_summary.get('node_count', 0),
|
|
'execution_time': workflow_summary.get('execution_time', 0),
|
|
'creation_app': 'ComfyUI',
|
|
'timestamp': datetime.now().isoformat()
|
|
}
|
|
}
|
|
}
|
|
|
|
# Store full workflow data
|
|
if workflow_data:
|
|
# Store in database only to avoid size issues in embedded/XMP
|
|
db_metadata = {
|
|
'ai_info': {
|
|
'workflow': workflow_data
|
|
}
|
|
}
|
|
self.metadata_service.write_metadata(
|
|
image_path, db_metadata, targets=['database']
|
|
)
|
|
|
|
# Write summary to all targets
|
|
self.metadata_service.write_metadata(
|
|
image_path, metadata, targets=['embedded', 'xmp']
|
|
)
|
|
|
|
return workflow_summary
|
|
|
|
return None
|
|
```
|
|
|
|
## 11. Working with Namespaces
|
|
|
|
When extending the metadata system with custom fields or integrating with specific applications, you may need to register custom namespaces:
|
|
|
|
```python
|
|
from eric_metadata.utils.namespace import NamespaceManager
|
|
|
|
def register_custom_namespaces(self):
|
|
"""Register custom namespaces for specialized metadata"""
|
|
|
|
# Register a custom namespace for your application
|
|
NamespaceManager.register_namespace(
|
|
'myapp',
|
|
'http://example.org/myapp/1.0/',
|
|
register_with_pyexiv2=True
|
|
)
|
|
|
|
# Register an industry standard namespace
|
|
NamespaceManager.register_namespace(
|
|
'plus',
|
|
'http://ns.useplus.org/ldf/xmp/1.0/',
|
|
register_with_pyexiv2=True
|
|
)
|
|
|
|
# Get all registered namespaces
|
|
all_namespaces = NamespaceManager.get_all_namespaces()
|
|
print(f"Available namespaces: {all_namespaces}")
|
|
```
|
|
|
|
## 12. Debugging
|
|
|
|
Enable debug mode for detailed logging:
|
|
|
|
```python
|
|
# Enable debug logging in the service constructor
|
|
metadata_service = MetadataService(debug=True)
|
|
|
|
# Or enable it later
|
|
metadata_service.set_debug(True)
|
|
|
|
# Configure logging level for specific handlers
|
|
metadata_service.configure_handler('embedded', debug=True)
|
|
```
|
|
|
|
Common debug patterns:
|
|
- Check for XMP namespace registration issues with `NamespaceManager.print_registered_namespaces()`
|
|
- Verify file permissions when writing fails
|
|
- For XMP sidecar issues, ensure resource identifiers are set correctly
|
|
|
|
This guide is a preview and will be updated based on testing results and feedback. If you encounter any issues or have suggestions, please share them to help improve the system. |