Files
Eric Rollei 1f9fa9b670 Add backward compatibility, XMP regions, per-node hooks toggle, and enhancements
Major Features:
- Backward compatibility: VALIDATE_INPUTS handles old boolean True/False values for enable_runtime_hooks parameter
- Phase 3: XMP region metadata with MWG-RS compliance (faces, areas, coordinates, extensions)
- Phase 4.4: Per-node UI toggle for runtime hooks (dropdown: Auto/Enabled/Disabled)
- Runtime hooks enhancements: performance tracking, disagreement logging, configurable limits

New Files:
- config.json: Configuration file for runtime hooks and metadata settings
- eric_metadata/hooks/runtime_capture.py: Runtime hook infrastructure
- eric_metadata/utils/config.py: Configuration loader with priority system
- eric_metadata/utils/version.py: Version helper for generated_by labels
- eric_metadata/utils/a1111.py: Automatic1111 parameter string generation
- eric_metadata/utils/jpeg_fallback.py: 4-stage JPEG metadata fallback
- eric_metadata/workflow/: Workflow parsing and generation helpers
- tests/test_backward_compatibility.py: 7 tests for parameter migration
- tests/test_xmp_regions.py: 6 tests for XMP region round-trip
- tests/test_per_node_runtime_hooks.py: 9 tests for per-node toggle

Documentation:
- CONFIG_GUIDE.md: Configuration options and usage
- RUNTIME_HOOKS_QUICK_REFERENCE.md: Quick reference for runtime hooks
- RUNTIME_HOOKS_ENHANCEMENTS_SUMMARY.md: Detailed enhancement documentation
- SAFE_RUNTIME_HOOKS_RESEARCH.md: Safety analysis and validation
- edit_log.md: Complete change history

Test Results: All 143 tests passing
- 7 backward compatibility tests
- 6 XMP region tests
- 9 per-node toggle tests
- 17 runtime hooks enhancement tests
- 104 existing tests

Breaking Changes: None - fully backward compatible
2025-11-20 01:26:04 -08:00

247 lines
8.3 KiB
Python

"""JPEG Metadata Fallback Utilities.
Implements 4-stage fallback for JPEG metadata to prevent EXIF size limit errors:
Stage 1 (Full): All metadata including workflow JSON
Stage 2 (Reduced): Drop workflow JSON, keep all other fields
Stage 3 (Minimal): Essential fields only (prompts, model, seed, steps, sampler, dimensions, LoRAs)
Stage 4 (Sidecar): Minimal EXIF + pointer to .json sidecar
Author: Eric Hiss
Date: 2025-11-18
"""
import json
from typing import Dict, Any, Tuple, Optional
from copy import deepcopy
# JPEG EXIF has practical limits - Adobe recommends staying under 60KB for compatibility
JPEG_EXIF_SIZE_THRESHOLD = 60 * 1024 # 60KB in bytes
def estimate_metadata_size(metadata: Dict[str, Any]) -> int:
"""
Estimate the serialized size of metadata in bytes.
This is an approximation used to predict if metadata will fit in EXIF.
Uses JSON serialization as a proxy for EXIF/XMP size.
Args:
metadata: Metadata dictionary
Returns:
Estimated size in bytes
"""
try:
# Serialize to JSON to get approximate size
json_str = json.dumps(metadata, default=str, ensure_ascii=False)
return len(json_str.encode('utf-8'))
except Exception:
# If serialization fails, return a large number to trigger fallback
return JPEG_EXIF_SIZE_THRESHOLD + 1
def trim_to_essential(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Trim metadata to essential fields only for minimal EXIF embedding.
Essential fields preserved:
- Prompts (positive and negative)
- Model information (name, hash)
- Seed
- Sampler settings (steps, cfg, sampler name, scheduler)
- Dimensions (width, height)
- LoRAs (name and strengths)
- Basic info (title, creator, timestamp)
Args:
metadata: Full metadata dictionary
Returns:
Trimmed metadata with only essential fields
"""
essential = {}
# Basic metadata (if present)
if 'basic' in metadata:
basic = metadata['basic']
essential['basic'] = {}
for key in ['title', 'creator', 'timestamp']:
if key in basic:
essential['basic'][key] = basic[key]
# AI generation info (essential parameters only)
if 'ai_info' in metadata and 'generation' in metadata['ai_info']:
gen = metadata['ai_info']['generation']
essential['ai_info'] = {'generation': {}}
ess_gen = essential['ai_info']['generation']
# Direct fields
for key in ['prompt', 'negative_prompt', 'seed', 'steps', 'cfg_scale',
'sampler', 'scheduler', 'width', 'height', 'denoise', 'timestamp']:
if key in gen:
ess_gen[key] = gen[key]
# Model info
if 'base_model' in gen:
model = gen['base_model']
ess_gen['base_model'] = {}
for key in ['name', 'hash', 'checkpoint']:
if key in model:
ess_gen['base_model'][key] = model[key]
# Sampling parameters (if grouped)
if 'sampling' in gen:
sampling = gen['sampling']
ess_gen['sampling'] = {}
for key in ['sampler', 'scheduler', 'steps', 'cfg_scale', 'seed', 'denoise']:
if key in sampling:
ess_gen['sampling'][key] = sampling[key]
# Dimensions (if grouped)
if 'dimensions' in gen:
dims = gen['dimensions']
ess_gen['dimensions'] = {}
for key in ['width', 'height', 'batch_size']:
if key in dims:
ess_gen['dimensions'][key] = dims[key]
# LoRAs
if 'loras' in gen:
ess_gen['loras'] = gen['loras'] # Keep all LoRA info as it's usually small
# Embeddings
if 'embeddings' in gen:
ess_gen['embeddings'] = gen['embeddings']
# Provenance (for traceability)
if 'provenance' in metadata:
prov = metadata['provenance']
essential['provenance'] = {}
for key in ['created_by', 'created_at', 'source', 'capture_mode']:
if key in prov:
essential['provenance'][key] = prov[key]
return essential
def create_reduced_metadata(metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Create reduced metadata by dropping workflow JSON but keeping all other fields.
This is Stage 2 of the fallback - removes the largest single item (workflow JSON)
which can often be 20-40KB or more.
Args:
metadata: Full metadata dictionary
Returns:
Metadata without workflow JSON
"""
reduced = deepcopy(metadata)
# Remove workflow data (largest contributor to metadata size)
if 'ai_info' in reduced and 'workflow' in reduced['ai_info']:
del reduced['ai_info']['workflow']
if 'ai_info' in reduced and 'generation' in reduced['ai_info']:
gen = reduced['ai_info']['generation']
# Remove workflow-related fields
for key in ['workflow', 'workflow_data', 'workflow_api']:
if key in gen:
del gen[key]
# Remove analysis data (can be regenerated)
if 'analysis' in reduced:
del reduced['analysis']
return reduced
def determine_fallback_stage(metadata: Dict[str, Any],
threshold: int = JPEG_EXIF_SIZE_THRESHOLD) -> Tuple[int, Dict[str, Any], int]:
"""
Determine which fallback stage is needed based on metadata size.
Args:
metadata: Full metadata dictionary
threshold: Size threshold in bytes (default 60KB)
Returns:
Tuple of (stage_number, metadata_to_use, estimated_size)
- stage_number: 1-4 indicating which stage was selected
- metadata_to_use: The metadata dict that should be written
- estimated_size: Estimated size in bytes of the metadata
"""
# Stage 1: Try full metadata
full_size = estimate_metadata_size(metadata)
if full_size <= threshold:
return (1, metadata, full_size)
# Stage 2: Try reduced (no workflow)
reduced = create_reduced_metadata(metadata)
reduced_size = estimate_metadata_size(reduced)
if reduced_size <= threshold:
return (2, reduced, reduced_size)
# Stage 3: Try minimal (essential only)
minimal = trim_to_essential(metadata)
minimal_size = estimate_metadata_size(minimal)
if minimal_size <= threshold:
return (3, minimal, minimal_size)
# Stage 4: Sidecar only
# Return very minimal metadata with a pointer
sidecar_minimal = {
'basic': {
'title': metadata.get('basic', {}).get('title', 'AI Generated Image'),
'description': 'Full metadata in sidecar file'
},
'provenance': {
'metadata_location': 'sidecar',
'created_by': metadata.get('provenance', {}).get('created_by', 'AAA_Metadata_System')
}
}
return (4, sidecar_minimal, estimate_metadata_size(sidecar_minimal))
def add_fallback_provenance(metadata: Dict[str, Any], stage: int,
original_size: int, final_size: int) -> Dict[str, Any]:
"""
Add provenance tracking for fallback stage to metadata.
Args:
metadata: Metadata dictionary to update
stage: Fallback stage used (1-4)
original_size: Original metadata size estimate
final_size: Final metadata size after fallback
Returns:
Updated metadata with provenance info
"""
if 'provenance' not in metadata:
metadata['provenance'] = {}
stage_names = {
1: 'full',
2: 'reduced',
3: 'minimal',
4: 'sidecar'
}
metadata['provenance']['jpeg_fallback_stage'] = stage
metadata['provenance']['jpeg_fallback_stage_name'] = stage_names.get(stage, 'unknown')
metadata['provenance']['jpeg_metadata_size_original'] = original_size
metadata['provenance']['jpeg_metadata_size_final'] = final_size
# Add description of what was done
descriptions = {
1: 'Full metadata embedded',
2: 'Workflow JSON removed to reduce size',
3: 'Only essential fields embedded',
4: 'Minimal EXIF with pointer to sidecar file'
}
metadata['provenance']['jpeg_fallback_description'] = descriptions.get(stage, 'Unknown fallback')
return metadata