Add files via upload
This commit is contained in:
@@ -1 +1,182 @@
|
||||
# ComfyUI-Metadata-Extractor
|
||||
# ComfyUI Metadata Extractor
|
||||
|
||||
A powerful custom node for ComfyUI that extracts metadata from images, videos, and audio files generated by ComfyUI.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Multiple File Format Support:**
|
||||
- **Images:** PNG, JPEG
|
||||
- **Videos:** MP4, AVI, MOV, MKV, WebM
|
||||
- **Audio:** FLAC, MP3, WAV, OGG
|
||||
|
||||
✅ **Comprehensive Metadata Extraction:**
|
||||
- Positive prompt
|
||||
- Negative prompt
|
||||
- Seed
|
||||
- Steps
|
||||
- CFG scale
|
||||
- Sampler name
|
||||
- Scheduler
|
||||
- Width/Height/Dimensions
|
||||
- Batch size
|
||||
- FPS (for videos)
|
||||
- Full workflow JSON
|
||||
|
||||
✅ **Two Node Variants:**
|
||||
1. **MetadataExtractor (File)** - Works with file paths
|
||||
2. **ImageMetadataExtractor (Image)** - Works with IMAGE tensor inputs
|
||||
|
||||
## Installation
|
||||
|
||||
### Method 1: Manual Installation
|
||||
|
||||
1. Navigate to your ComfyUI custom_nodes directory:
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
```
|
||||
|
||||
2. Create a new folder for the node:
|
||||
```bash
|
||||
mkdir ComfyUI-Metadata-Extractor
|
||||
cd ComfyUI-Metadata-Extractor
|
||||
```
|
||||
|
||||
3. Copy these files into the folder:
|
||||
- `__init__.py`
|
||||
- `metadata_extractor_node.py`
|
||||
|
||||
4. Install required dependencies:
|
||||
```bash
|
||||
pip install pillow mutagen
|
||||
```
|
||||
|
||||
5. Restart ComfyUI
|
||||
|
||||
### Method 2: Git Clone (if you create a repo)
|
||||
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
git clone https://github.com/yourusername/ComfyUI-Metadata-Extractor.git
|
||||
cd ComfyUI-Metadata-Extractor
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### MetadataExtractor (File Path)
|
||||
|
||||
1. Add the **"Extract Metadata (File)"** node to your workflow
|
||||
2. Connect a file path string to the `file_path` input
|
||||
3. All metadata outputs will be available as separate connections:
|
||||
- `positive_prompt` (STRING)
|
||||
- `negative_prompt` (STRING)
|
||||
- `seed` (STRING)
|
||||
- `steps` (STRING)
|
||||
- `width` (INT)
|
||||
- `height` (INT)
|
||||
- `batch_size` (INT)
|
||||
- `cfg` (FLOAT)
|
||||
- `sampler_name` (STRING)
|
||||
- `scheduler` (STRING)
|
||||
- `full_workflow_json` (STRING)
|
||||
|
||||
### ImageMetadataExtractor (Image Input)
|
||||
|
||||
1. Add the **"Extract Metadata (Image)"** node to your workflow
|
||||
2. Connect an IMAGE output to the `image` input
|
||||
3. Optionally provide the source file path for full metadata extraction
|
||||
4. Outputs:
|
||||
- `prompt` (STRING)
|
||||
- `negative_prompt` (STRING)
|
||||
- `width` (INT)
|
||||
- `height` (INT)
|
||||
- `metadata_json` (STRING)
|
||||
|
||||
## Example Workflows
|
||||
|
||||
### Example 1: Extract and Reuse Prompts
|
||||
|
||||
Load an image → Extract Metadata (File) → Connect `positive_prompt` to CLIPTextEncode → Generate similar image
|
||||
|
||||
### Example 2: Batch Processing
|
||||
|
||||
Load multiple images → Extract Metadata → Save prompts to text files → Analyze generation patterns
|
||||
|
||||
### Example 3: Video Analysis
|
||||
|
||||
Load video path → Extract Metadata (File) → Get FPS, dimensions, and generation settings
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How It Works
|
||||
|
||||
**For PNG Images:**
|
||||
- Reads metadata from PNG `info` dictionary
|
||||
- Extracts `workflow` and `prompt` JSON data
|
||||
- Parses node structure to find generation parameters
|
||||
|
||||
**For Videos:**
|
||||
- Uses ffprobe to read video metadata
|
||||
- Looks for ComfyUI metadata in format tags
|
||||
- Extracts dimensions and FPS from stream info
|
||||
|
||||
**For Audio:**
|
||||
- Uses mutagen library to read audio tags
|
||||
- Searches for JSON metadata in comment/description fields
|
||||
- Preserves generation parameters
|
||||
|
||||
### Metadata Structure
|
||||
|
||||
ComfyUI stores metadata in a node-based JSON structure. This node intelligently parses:
|
||||
- KSampler nodes for sampling parameters
|
||||
- CLIPTextEncode nodes for prompts
|
||||
- EmptyLatentImage nodes for dimensions
|
||||
- Custom metadata fields
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Python 3.8+
|
||||
- PIL/Pillow (image handling)
|
||||
- mutagen (audio metadata)
|
||||
- ffprobe (video metadata, optional but recommended)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No metadata found"**
|
||||
- Ensure the file was generated by ComfyUI with metadata saving enabled
|
||||
- Check that the file hasn't been re-encoded or processed by other tools
|
||||
|
||||
**Video metadata extraction fails**
|
||||
- Install ffmpeg/ffprobe: `apt-get install ffmpeg` or download from ffmpeg.org
|
||||
- Ensure ffprobe is in your system PATH
|
||||
|
||||
**Audio metadata extraction fails**
|
||||
- Install mutagen: `pip install mutagen`
|
||||
- Some audio formats may not preserve metadata after encoding
|
||||
|
||||
## Limitations
|
||||
|
||||
- Only extracts metadata that was embedded during generation
|
||||
- Images downloaded from web sources typically have metadata stripped
|
||||
- Video metadata preservation depends on the encoder settings
|
||||
- Some file formats have limited metadata support
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Support for more video formats
|
||||
- [ ] Batch processing multiple files
|
||||
- [ ] Export metadata to CSV/JSON files
|
||||
- [ ] Metadata comparison between files
|
||||
- [ ] Automatic prompt reconstruction from partial data
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to submit issues, feature requests, or pull requests!
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Free to use and modify
|
||||
|
||||
## Credits
|
||||
|
||||
Created for the ComfyUI community. Special thanks to the ComfyUI developers and the AI art community.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
ComfyUI Metadata Extractor
|
||||
Extracts metadata from PNG images and MP4/video files generated by ComfyUI
|
||||
"""
|
||||
|
||||
from .metadata_extractor import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
ComfyUI Metadata Extractor - Improved Version
|
||||
Extracts metadata from PNG images and MP4/video files
|
||||
Now properly extracts: prompts, seed, steps, cfg, sampler, scheduler
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
import PIL.Image as Image
|
||||
|
||||
|
||||
class MetadataExtractorImproved:
|
||||
"""
|
||||
Extracts metadata from ComfyUI-generated files
|
||||
Properly parses seed, steps, cfg from the prompt structure
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.input_dir = folder_paths.get_input_directory()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
input_dir = folder_paths.get_input_directory()
|
||||
files = []
|
||||
|
||||
try:
|
||||
files = [f for f in os.listdir(input_dir)
|
||||
if os.path.isfile(os.path.join(input_dir, f)) and
|
||||
f.lower().endswith(('.png', '.jpg', '.jpeg', '.mp4', '.avi', '.mov', '.mkv', '.webm'))]
|
||||
except:
|
||||
pass
|
||||
|
||||
return {
|
||||
"required": {},
|
||||
"optional": {
|
||||
"video": ("IMAGE",),
|
||||
"file_path": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"placeholder": "Full path to file"
|
||||
}),
|
||||
"filename": ([""] + sorted(files), {
|
||||
"default": ""
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "STRING", "INT", "INT", "FLOAT", "STRING", "STRING", "STRING", "STRING")
|
||||
RETURN_NAMES = ("positive_prompt", "negative_prompt", "seed", "steps", "cfg", "sampler", "scheduler", "metadata_json", "file_info")
|
||||
FUNCTION = "extract"
|
||||
CATEGORY = "utils/metadata"
|
||||
OUTPUT_NODE = False
|
||||
|
||||
def find_video_file_in_workflow(self, video_tensor):
|
||||
"""Try to find source video file"""
|
||||
possible_locations = [self.input_dir, self.output_dir]
|
||||
video_files = []
|
||||
|
||||
for location in possible_locations:
|
||||
if not os.path.exists(location):
|
||||
continue
|
||||
try:
|
||||
for f in os.listdir(location):
|
||||
if f.lower().endswith(('.mp4', '.avi', '.mov', '.mkv', '.webm')):
|
||||
full_path = os.path.join(location, f)
|
||||
video_files.append((full_path, os.path.getmtime(full_path)))
|
||||
except:
|
||||
pass
|
||||
|
||||
video_files.sort(key=lambda x: x[1], reverse=True)
|
||||
if video_files:
|
||||
print(f"[Metadata] 💡 Found: {os.path.basename(video_files[0][0])}")
|
||||
return video_files[0][0]
|
||||
return None
|
||||
|
||||
def extract_video_metadata(self, file_path):
|
||||
"""Extract metadata from video files using ffprobe"""
|
||||
try:
|
||||
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json',
|
||||
'-show_format', '-show_streams', file_path]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
video_info = json.loads(result.stdout)
|
||||
tags = video_info.get('format', {}).get('tags', {})
|
||||
|
||||
print(f"[Metadata] Video tags: {list(tags.keys())}")
|
||||
|
||||
metadata = {}
|
||||
metadata_fields = ['comment', 'Comment', 'description', 'Description',
|
||||
'workflow', 'Workflow', 'prompt', 'Prompt']
|
||||
|
||||
for field in metadata_fields:
|
||||
if field in tags:
|
||||
try:
|
||||
parsed = json.loads(tags[field])
|
||||
metadata[field.lower()] = parsed
|
||||
print(f"[Metadata] ✅ Found '{field}' tag")
|
||||
except:
|
||||
metadata[field.lower()] = tags[field]
|
||||
|
||||
# Get video properties
|
||||
for stream in video_info.get('streams', []):
|
||||
if stream.get('codec_type') == 'video':
|
||||
metadata['video_width'] = stream.get('width', 0)
|
||||
metadata['video_height'] = stream.get('height', 0)
|
||||
fps_str = stream.get('r_frame_rate', '0/1')
|
||||
try:
|
||||
fps_num, fps_den = map(int, fps_str.split('/'))
|
||||
metadata['video_fps'] = fps_num / fps_den if fps_den else 0
|
||||
except:
|
||||
metadata['video_fps'] = 0
|
||||
metadata['video_duration'] = float(stream.get('duration', 0))
|
||||
break
|
||||
|
||||
return metadata if metadata else None
|
||||
|
||||
except FileNotFoundError:
|
||||
print("[Metadata] ❌ ffprobe not found! Install ffmpeg")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[Metadata] Error: {e}")
|
||||
return None
|
||||
|
||||
def extract_png_metadata(self, file_path):
|
||||
"""Extract metadata from PNG files"""
|
||||
try:
|
||||
img = Image.open(file_path)
|
||||
metadata = {}
|
||||
|
||||
if hasattr(img, 'info'):
|
||||
if 'workflow' in img.info:
|
||||
try:
|
||||
metadata['workflow'] = json.loads(img.info['workflow'])
|
||||
except:
|
||||
pass
|
||||
if 'prompt' in img.info:
|
||||
try:
|
||||
metadata['prompt'] = json.loads(img.info['prompt'])
|
||||
except:
|
||||
pass
|
||||
|
||||
return metadata if metadata else None
|
||||
except Exception as e:
|
||||
print(f"[Metadata] Error reading PNG: {e}")
|
||||
return None
|
||||
|
||||
def parse_comfyui_metadata(self, metadata):
|
||||
"""Parse metadata and extract ALL parameters"""
|
||||
result = {
|
||||
'positive_prompt': "",
|
||||
'negative_prompt': "",
|
||||
'seed': 0,
|
||||
'steps': 0,
|
||||
'cfg': 0.0,
|
||||
'sampler': "",
|
||||
'scheduler': "",
|
||||
'metadata_json': "{}",
|
||||
'file_info': ""
|
||||
}
|
||||
|
||||
try:
|
||||
# Get prompt data (node-based structure)
|
||||
prompt_data = None
|
||||
|
||||
if 'prompt' in metadata:
|
||||
prompt_data = metadata['prompt']
|
||||
elif 'comment' in metadata and isinstance(metadata['comment'], dict):
|
||||
prompt_data = metadata['comment']
|
||||
|
||||
if not prompt_data:
|
||||
result['metadata_json'] = json.dumps(metadata, indent=2)
|
||||
return result
|
||||
|
||||
print(f"[Metadata] Parsing {len(prompt_data)} nodes...")
|
||||
|
||||
# Track what we've found
|
||||
positive_found = False
|
||||
negative_found = False
|
||||
|
||||
# Iterate through all nodes
|
||||
for node_id, node_data in prompt_data.items():
|
||||
if not isinstance(node_data, dict):
|
||||
continue
|
||||
|
||||
inputs = node_data.get('inputs', {})
|
||||
class_type = node_data.get('class_type', '')
|
||||
|
||||
# Extract prompts
|
||||
if class_type in ['CLIPTextEncode', 'CLIPTextEncodeSDXL', 'CLIPTextEncodeFlux']:
|
||||
text_content = inputs.get('text', '')
|
||||
if text_content:
|
||||
if not positive_found:
|
||||
result['positive_prompt'] = text_content
|
||||
positive_found = True
|
||||
print(f"[Metadata] ✅ Positive prompt")
|
||||
elif not negative_found:
|
||||
result['negative_prompt'] = text_content
|
||||
negative_found = True
|
||||
print(f"[Metadata] ✅ Negative prompt")
|
||||
|
||||
# Extract seed from RandomNoise node
|
||||
if class_type == 'RandomNoise':
|
||||
if 'noise_seed' in inputs:
|
||||
result['seed'] = int(inputs['noise_seed'])
|
||||
print(f"[Metadata] ✅ Seed: {result['seed']}")
|
||||
elif isinstance(inputs.get('noise_seed'), list):
|
||||
# Handle array format: ["get", ["123", 0]]
|
||||
try:
|
||||
result['seed'] = int(inputs['noise_seed'][0])
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract sampler
|
||||
if class_type == 'KSamplerSelect':
|
||||
sampler_name = inputs.get('sampler_name', '')
|
||||
if sampler_name:
|
||||
result['sampler'] = sampler_name
|
||||
print(f"[Metadata] ✅ Sampler: {sampler_name}")
|
||||
|
||||
# Extract steps and cfg from scheduler nodes
|
||||
if class_type in ['LTXVScheduler', 'BasicScheduler', 'KScheduler']:
|
||||
if 'steps' in inputs:
|
||||
result['steps'] = int(inputs['steps'])
|
||||
print(f"[Metadata] ✅ Steps: {result['steps']}")
|
||||
|
||||
# Extract CFG from guider nodes
|
||||
if class_type in ['CFGGuider', 'DualCFGGuider']:
|
||||
if 'cfg' in inputs:
|
||||
result['cfg'] = float(inputs['cfg'])
|
||||
print(f"[Metadata] ✅ CFG: {result['cfg']}")
|
||||
|
||||
# Store full metadata
|
||||
result['metadata_json'] = json.dumps(metadata, indent=2)
|
||||
|
||||
# Create file info
|
||||
info_parts = []
|
||||
if 'video_width' in metadata:
|
||||
info_parts.append(f"{metadata['video_width']}x{metadata['video_height']}")
|
||||
if 'video_fps' in metadata:
|
||||
info_parts.append(f"{metadata['video_fps']:.2f} FPS")
|
||||
if 'video_duration' in metadata:
|
||||
info_parts.append(f"{metadata['video_duration']:.2f}s")
|
||||
|
||||
result['file_info'] = " | ".join(info_parts) if info_parts else "PNG Image"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Metadata] Error parsing: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return result
|
||||
|
||||
def extract(self, video=None, file_path="", filename=""):
|
||||
"""Main extraction function"""
|
||||
|
||||
actual_path = None
|
||||
|
||||
if file_path and os.path.exists(file_path):
|
||||
actual_path = file_path
|
||||
elif filename:
|
||||
actual_path = os.path.join(self.input_dir, filename)
|
||||
elif video is not None:
|
||||
actual_path = self.find_video_file_in_workflow(video)
|
||||
|
||||
if not actual_path or not os.path.exists(actual_path):
|
||||
return ("", "", 0, 0, 0.0, "", "", "{}", "File not found")
|
||||
|
||||
print(f"[Metadata] 📂 Processing: {os.path.basename(actual_path)}")
|
||||
|
||||
ext = os.path.splitext(actual_path)[1].lower()
|
||||
|
||||
metadata = None
|
||||
if ext in ['.png', '.jpg', '.jpeg']:
|
||||
metadata = self.extract_png_metadata(actual_path)
|
||||
elif ext in ['.mp4', '.avi', '.mov', '.mkv', '.webm']:
|
||||
metadata = self.extract_video_metadata(actual_path)
|
||||
else:
|
||||
return ("", "", 0, 0, 0.0, "", "", "{}", f"Unsupported: {ext}")
|
||||
|
||||
if not metadata:
|
||||
return ("", "", 0, 0, 0.0, "", "", "{}", "No metadata found")
|
||||
|
||||
result = self.parse_comfyui_metadata(metadata)
|
||||
|
||||
return (
|
||||
result['positive_prompt'],
|
||||
result['negative_prompt'],
|
||||
result['seed'],
|
||||
result['steps'],
|
||||
result['cfg'],
|
||||
result['sampler'],
|
||||
result['scheduler'],
|
||||
result['metadata_json'],
|
||||
result['file_info']
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"MetadataExtractorImproved": MetadataExtractorImproved
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"MetadataExtractorImproved": "📹 Extract Metadata (PNG/Video)"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
Pillow>=9.0.0
|
||||
mutagen>=1.45.0
|
||||
Reference in New Issue
Block a user