Files
Jason Kende ef615fb956 Transform from prescriptive diagnostics to investigative infrastructure
- Implement Green Tree Restaurant principle: create conditions for discovery
- Replace string outputs with on-node visual displays using addDOMWidget()
- Add retrotech aesthetics: ASCII histograms, phosphor glow, terminal readouts
- Create flexible investigation modes (tensor, temporal, patterns)
- Fix security vulnerabilities: input sanitization, workflow isolation, memory cleanup
- Enable temporal tracking for FOUC-type frame-by-frame analysis
2025-06-25 20:58:11 -04:00

306 lines
11 KiB
Python

"""
ComfyUI-Satori: Investigative Infrastructure for Creative Workflows
Based on the Green Tree Restaurant principle - create conditions for discovery
"""
import torch
import numpy as np
import json
from collections import deque
import time
# Web directory for custom UI components
WEB_DIRECTORY = "./web"
class InvestigationContext:
"""Tracks data through pipeline stages for pattern discovery"""
def __init__(self):
self.history = deque(maxlen=100) # Rolling window of measurements
self.patterns = {} # Discovered patterns
self.timestamps = deque(maxlen=100) # Timing data
def record(self, data):
"""Record investigation data without interpretation"""
self.history.append(data)
self.timestamps.append(time.time())
def get_temporal_delta(self, frames_back=1):
"""Compare current with previous states"""
if len(self.history) >= frames_back + 1:
return {
'current': self.history[-1],
'previous': self.history[-frames_back-1],
'frames_back': frames_back
}
return None
# Per-workflow investigation contexts to prevent data leakage
investigation_contexts = {}
MAX_CONTEXTS = 10 # Prevent memory leak from abandoned workflows
def cleanup_old_contexts():
"""Remove oldest contexts if we exceed maximum"""
if len(investigation_contexts) > MAX_CONTEXTS:
# Remove oldest half
sorted_keys = sorted(investigation_contexts.keys())
for key in sorted_keys[:len(sorted_keys)//2]:
del investigation_contexts[key]
class WhyDidItBreak:
"""
Investigative node that reveals what's happening in your workflow
without prescribing what you should look for
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"investigation_id": ("STRING", {"default": "investigation_1"}),
},
"optional": {
"analysis_modes": ("STRING", {
"default": "tensor,temporal,patterns",
"multiline": False
}),
"frame_memory": ("INT", {"default": 10, "min": 1, "max": 100}),
}
}
RETURN_TYPES = ("IMAGE",) # Only passthrough - display happens on node
RETURN_NAMES = ("image",)
FUNCTION = "investigate"
CATEGORY = "ComfyUI-Satori"
OUTPUT_NODE = True # Important: allows execution without output connections
def investigate(self, image, investigation_id, analysis_modes="tensor,temporal,patterns", frame_memory=10):
"""
Investigate without assumptions about what matters
All display happens via JavaScript widgets
"""
# Parse analysis modes (limit to prevent DoS)
modes = [m.strip() for m in analysis_modes.split(',')[:10]] # Max 10 modes
# Sanitize investigation_id to prevent XSS
safe_id = str(investigation_id)[:50].replace('<', '').replace('>', '').replace('"', '').replace("'", '')
# Prepare investigation data
investigation_data = {
'id': safe_id,
'timestamp': time.time(),
'modes': modes,
'findings': {}
}
# Tensor investigation - what's actually in the data?
if 'tensor' in modes:
investigation_data['findings']['tensor'] = self._investigate_tensor(image)
# Get or create workflow-specific context
workflow_id = getattr(self, 'workflow_id', 'default')
if workflow_id not in investigation_contexts:
cleanup_old_contexts() # Prevent memory leak
investigation_contexts[workflow_id] = InvestigationContext()
context = investigation_contexts[workflow_id]
# Temporal investigation - what changed?
if 'temporal' in modes and len(context.history) > 0:
investigation_data['findings']['temporal'] = self._investigate_temporal(image, context)
# Pattern investigation - what repeats or stands out?
if 'patterns' in modes:
investigation_data['findings']['patterns'] = self._investigate_patterns(image)
# Record for future investigations
context.record({
'tensor_hash': self._tensor_hash(image),
'stats': investigation_data['findings'].get('tensor', {}),
'investigation_id': safe_id
})
# Store data for JavaScript widget to display
self.investigation_data = investigation_data
# Return image unchanged - investigation is about observation not modification
return {"ui": {"investigation_data": investigation_data}, "result": (image,)}
def _investigate_tensor(self, tensor):
"""Reveal tensor characteristics without interpretation"""
# Convert to numpy for analysis
if isinstance(tensor, torch.Tensor):
data = tensor.cpu().numpy()
else:
data = np.array(tensor)
# Get basic shape info
findings = {
'shape': list(data.shape),
'dtype': str(data.dtype),
'device': str(tensor.device) if isinstance(tensor, torch.Tensor) else 'cpu',
}
# Statistical landscape
flat_data = data.flatten()
findings['landscape'] = {
'min': float(np.min(flat_data)),
'max': float(np.max(flat_data)),
'mean': float(np.mean(flat_data)),
'median': float(np.median(flat_data)),
'std': float(np.std(flat_data)),
}
# Distribution insights
findings['distribution'] = {
'unique_values': int(np.unique(flat_data).size),
'zeros': float(np.sum(flat_data == 0) / flat_data.size),
'ones': float(np.sum(flat_data == 1) / flat_data.size),
}
# Percentile mapping
percentiles = [1, 5, 25, 50, 75, 95, 99]
findings['percentiles'] = {
f'p{p}': float(np.percentile(flat_data, p))
for p in percentiles
}
# Channel investigation if applicable
if len(data.shape) >= 3 and data.shape[-1] in [3, 4]:
findings['channels'] = self._investigate_channels(data)
return findings
def _investigate_temporal(self, tensor, context):
"""Discover changes over time without assuming what matters"""
current_hash = self._tensor_hash(tensor)
# Find how this differs from recent history
findings = {
'changes_detected': [],
'stability_score': 0.0,
'pattern_breaks': []
}
# Look for changes at different time scales
for frames_back in [1, 5, 10]:
delta = context.get_temporal_delta(frames_back)
if delta:
change_magnitude = self._calculate_change(
delta['current'].get('stats', {}),
delta['previous'].get('stats', {})
)
findings['changes_detected'].append({
'frames_back': frames_back,
'magnitude': change_magnitude
})
return findings
def _investigate_patterns(self, tensor):
"""Look for patterns without prescribing what patterns mean"""
findings = {
'spatial_patterns': [],
'value_clusters': [],
'anomaly_scores': {}
}
# This is where pattern detection would happen
# For now, return structure for future expansion
return findings
def _investigate_channels(self, data):
"""Channel-wise investigation"""
channels = {}
channel_names = ['red', 'green', 'blue', 'alpha'] if data.shape[-1] == 4 else ['red', 'green', 'blue']
for i, name in enumerate(channel_names[:data.shape[-1]]):
channel_data = data[..., i].flatten()
channels[name] = {
'mean': float(np.mean(channel_data)),
'std': float(np.std(channel_data)),
'active': float(np.sum(channel_data > 0.01) / channel_data.size)
}
# Channel relationships
if data.shape[-1] >= 3:
channels['balance'] = {
'rg_correlation': float(np.corrcoef(data[..., 0].flatten(), data[..., 1].flatten())[0, 1]),
'gb_correlation': float(np.corrcoef(data[..., 1].flatten(), data[..., 2].flatten())[0, 1]),
'rb_correlation': float(np.corrcoef(data[..., 0].flatten(), data[..., 2].flatten())[0, 1]),
}
return channels
def _tensor_hash(self, tensor):
"""Create a hash for comparing tensors"""
if isinstance(tensor, torch.Tensor):
return hash(tuple(tensor.shape) + (tensor.sum().item(),))
else:
return hash(tuple(tensor.shape) + (np.sum(tensor),))
def _calculate_change(self, current_stats, previous_stats):
"""Calculate magnitude of change between states"""
if not current_stats or not previous_stats:
return 0.0
# Compare landscapes
current_landscape = current_stats.get('landscape', {})
previous_landscape = previous_stats.get('landscape', {})
changes = []
for key in ['mean', 'std', 'min', 'max']:
if key in current_landscape and key in previous_landscape:
if previous_landscape[key] != 0:
change = abs(current_landscape[key] - previous_landscape[key]) / abs(previous_landscape[key])
changes.append(change)
return float(np.mean(changes)) if changes else 0.0
class TemporalInvestigator:
"""
Specialized node for frame-by-frame investigation
Useful for FOUC-type issues where timing matters
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"frame_identifier": ("STRING", {"default": ""}),
},
"optional": {
"reference_frame": ("INT", {"default": 0, "min": 0}),
"comparison_window": ("INT", {"default": 5, "min": 1, "max": 50}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "investigate_temporal"
CATEGORY = "ComfyUI-Satori"
OUTPUT_NODE = True
def investigate_temporal(self, image, frame_identifier="", reference_frame=0, comparison_window=5):
"""
Track changes across frames without assuming what changes matter
"""
# Implementation focused on temporal patterns
# Display happens via widgets
return (image,)
# Node registration
NODE_CLASS_MAPPINGS = {
"WhyDidItBreak": WhyDidItBreak,
"TemporalInvestigator": TemporalInvestigator,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"WhyDidItBreak": "why did it break?",
"TemporalInvestigator": "temporal investigator",
}