Files
Krishnan RamachandranandClaude Sonnet 4.6 e8e7ad545c feat: node auto-sizing, wheel zoom in viewer, and fix image ordering
- Auto-expand node width to 700px min and recalculate height after all
  thumbnails load (double-rAF) so content no longer overflows the node
- Add mouse wheel zoom (0.5x–10x) in fullscreen viewer with smooth
  transition; resets on image navigation
- Fix image ordering: swap Python loop and JS index formula to mode-outer/
  camera-inner so arrows navigate all cameras per mode, not all modes per camera
- Fix viewer title labels (mode/cam were swapped after reorder)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:25:40 +00:00

232 lines
7.9 KiB
Python

import subprocess
import sys
import time
import os
import json
from uuid import uuid4
import folder_paths
# Path to the agnirt binary bundled with this plugin
PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__))
if sys.platform == "win32":
AGNIRT_DIR = os.path.join(PLUGIN_DIR, "bin", "win-x64")
AGNIRT_BIN = os.path.join(AGNIRT_DIR, "agnirt.exe")
else:
AGNIRT_DIR = os.path.join(PLUGIN_DIR, "bin", "linux-x64")
AGNIRT_BIN = os.path.join(AGNIRT_DIR, "agnirt")
# agnirt output segment names (must match what the binary emits)
CAMERAS = ["perspective", "front", "right", "left"]
MODES = ["pathtracing", "wireframe", "geometry-analysis", "sliver-triangles"]
# Human-readable display labels for the carousel
CAMERA_DISPLAY = {
"perspective": "Perspective",
"front": "Front",
"right": "Right",
"left": "Left",
}
MODE_DISPLAY = {
"pathtracing": "Path Trace",
"wireframe": "Wireframe",
"geometry-analysis": "Geo Analysis",
"sliver-triangles": "Sliver Tri",
}
def _parse_audit_log(log_path, total_faces):
"""
Parse audit_log.json and extract mesh statistics.
Args:
log_path: Path to audit_log.json file
total_faces: Total face count (used to calculate degenerate % if needed)
Returns:
Dict with keys: scene, geometry, quality_metrics
Returns None if file not found or parse error
"""
try:
with open(log_path, 'r') as f:
data = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Warning: Could not parse audit_log.json: {e}")
return None
try:
# Extract scene metadata
asset_name = data.get("asset", {}).get("name", "Unknown")
timestamp = data.get("asset", {}).get("timestamp", "N/A")
# Extract geometry stats
scene = data.get("scene_stats", {})
edge_count = scene.get("edge_count", 0)
face_count = scene.get("face_count", 0)
vertex_count = scene.get("vertex_count", 0)
# Extract validation metrics
validation = data.get("validation", {})
degen_count = validation.get("degenerate_triangles", 0)
inverted_count = validation.get("inverted_triangles", 0)
sliver_data = validation.get("sliver_triangles", {})
sliver_pct = sliver_data.get("percentage", 0.0) if isinstance(sliver_data, dict) else 0.0
# Calculate percentages (avoid division by zero)
degen_pct = (degen_count / face_count * 100) if face_count > 0 else 0.0
inverted_pct = (inverted_count / face_count * 100) if face_count > 0 else 0.0
return {
"scene": {
"name": asset_name,
"timestamp": timestamp
},
"geometry": {
"edge_count": edge_count,
"face_count": face_count,
"vertex_count": vertex_count
},
"quality_metrics": {
"degenerate_triangles_pct": round(degen_pct, 2),
"sliver_triangles_pct": round(sliver_pct, 2),
"inverted_triangles_pct": round(inverted_pct, 2)
}
}
except Exception as e:
print(f"Warning: Error extracting stats from audit_log.json: {e}")
return None
class PulseMeshAudit:
CATEGORY = "Pulse/MeshAudit"
RETURN_TYPES = ()
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {"default": ""}),
}
}
FUNCTION = "execute"
def execute(self, file_path):
# Resolve relative paths — check output directory first, then input directory
if not os.path.isabs(file_path):
output_candidate = os.path.join(folder_paths.get_output_directory(), file_path)
input_candidate = os.path.join(folder_paths.get_input_directory(), file_path)
if os.path.isfile(output_candidate):
file_path = output_candidate
else:
file_path = input_candidate
if not os.path.isfile(file_path):
raise FileNotFoundError(f"Mesh file not found: {file_path}")
# Output base: agnirt appends _{mode}_{camera}.png automatically
run_id = str(uuid4())[:8]
temp_dir = folder_paths.get_temp_directory()
output_base = os.path.join(temp_dir, f"ma_{run_id}.png")
# Need lib dir on LD_LIBRARY_PATH for libcrt_vulkan.so
env = os.environ.copy()
prev_ldpath = env.get("LD_LIBRARY_PATH", "")
env["LD_LIBRARY_PATH"] = f"{AGNIRT_DIR}:{prev_ldpath}" if prev_ldpath else AGNIRT_DIR
print("[MeshAudit] Starting agnirt...")
proc = subprocess.Popen(
[
AGNIRT_BIN, "vulkan", file_path,
"-headless",
"-shading-mode", "all",
"--camera", "perspective", "front", "right", "left",
"-env", "../../assets/sunny_rose_garden_4k.exr",
"-o", output_base,
],
cwd=AGNIRT_DIR, # agnirt reads assets/ relative to cwd
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stdout_lines = []
for line in proc.stdout:
line = line.rstrip()
stdout_lines.append(line)
print(f"[agnirt] {line}", flush=True)
proc.wait()
stderr_out = proc.stderr.read()
if proc.returncode != 0:
raise RuntimeError(
f"agnirt failed (rc={proc.returncode}):\n{stderr_out[-1000:]}"
)
# Expose result-like interface for downstream code
class _Result:
stdout = "\n".join(stdout_lines)
result = _Result()
# Build image list + carousel labels in camera-outer, mode-inner order
images = []
labels = []
for mode in MODES:
for cam in CAMERAS:
filename = f"ma_{run_id}_{mode}_{cam}.png"
full_path = os.path.join(temp_dir, filename)
if not os.path.isfile(full_path):
raise FileNotFoundError(
f"Expected agnirt output missing: {filename}\n"
f"agnirt stdout: {result.stdout[-500:]}"
)
images.append({"filename": filename, "subfolder": "", "type": "temp"})
labels.append(f"{CAMERA_DISPLAY[cam]} • {MODE_DISPLAY[mode]}")
# Locate and parse audit_log.json if available
# agnirt creates audit_log_<name>_<timestamp>.json, find the latest one
asset_stats = None
import glob
# Try multiple possible locations for audit_log
possible_temps = [
temp_dir,
"/home/krishnan/ai_lab/apps/ComfyUI/temp",
os.path.expanduser("~/ai_lab/apps/ComfyUI/temp"),
]
audit_log_path = None
for search_dir in possible_temps:
if os.path.isdir(search_dir):
audit_logs = glob.glob(os.path.join(search_dir, "audit_log_*.json"))
if audit_logs:
audit_log_path = max(audit_logs, key=os.path.getctime)
break
if audit_log_path:
print(f"[MeshAudit] Found audit log: {audit_log_path}")
asset_stats = _parse_audit_log(audit_log_path, len(MODES) * len(CAMERAS))
if asset_stats:
print(f"[MeshAudit] Parsed stats: {asset_stats['scene']['name']}")
else:
print(f"[MeshAudit] Could not find audit_log in any temp directory")
response = {
"ui": {
"images": images,
"mesh_audit_carousel": [{"labels": labels}],
}
}
# Add asset_stats if parsing succeeded
if asset_stats:
response["ui"]["asset_stats"] = [asset_stats]
return response
@classmethod
def IS_CHANGED(cls, **kwargs):
return time.time()