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
247 lines
8.3 KiB
Python
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
|