diff --git a/README.md b/README.md index 181de42..54d1a27 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ A powerful, extensible ComfyUI custom node with a modular **Views Extension Syst | View Name | View Description | View Link | |-----------|------------------|-----------| | **Image Search** | Search input/output/temp directories for images by input image | [Image Search](https://github.com/WASasquatch/ComfyUI_Viewer_Image_Search_Extension) | +| **OpenReel Video** | Edit videos generated within ComfyUI (e.g., from AnimateDiff, SVD, frame interpolation, etc.) | [OpenReel Video](https://github.com/WASasquatch/ComfyUI_Viewer_OpenReel_Extension) | ## Built-in Views diff --git a/__init__.py b/__init__.py index 0ec8399..279f4e2 100644 --- a/__init__.py +++ b/__init__.py @@ -3,7 +3,6 @@ import logging import os import pkgutil import time -import traceback import json logger = logging.getLogger("WAS.ContentViewer") @@ -13,6 +12,7 @@ class AnyType(str): def __ne__(self, __value: object) -> bool: return False + any_type = AnyType("*") @@ -59,35 +59,45 @@ class WASComfyViewer: except Exception as e2: logger.error(f"[WAS Viewer] String conversion failed: {e2}") return "Content exists but could not be serialized." - + if content is None: content = [] if not isinstance(content, list): content = [content] - + if manual_content is None: manual_content = [] if not isinstance(manual_content, list): manual_content = [manual_content] - + excluded = [] if viewer_meta: meta_str = viewer_meta[0] if isinstance(viewer_meta, list) else viewer_meta try: parsed = json.loads(meta_str) if isinstance(parsed, dict) and "excluded" in parsed: - excluded = parsed["excluded"] if isinstance(parsed["excluded"], list) else [] + excluded = ( + parsed["excluded"] + if isinstance(parsed["excluded"], list) + else [] + ) except (json.JSONDecodeError, TypeError, KeyError): excluded = [] - - content_trimmed = [c[:256] if isinstance(c, str) else str(c)[:256] for c in content] - manual_content_trimmed = [c[:256] if isinstance(c, str) else str(c)[:256] for c in manual_content] - + + content_trimmed = [ + c[:256] if isinstance(c, str) else str(c)[:256] for c in content + ] + manual_content_trimmed = [ + c[:256] if isinstance(c, str) else str(c)[:256] for c in manual_content + ] + view_state_trimmed = str(view_state)[:256] if view_state else "None" - logger.info(f"\n[WAS Viewer] Content:\n{content_trimmed}\nManual Content:\n{manual_content_trimmed}\nExcluded: {excluded}\nView State: {view_state_trimmed}\n") - + logger.info( + f"\n[WAS Viewer] Content:\n{content_trimmed}\nManual Content:\n{manual_content_trimmed}\nExcluded: {excluded}\nView State: {view_state_trimmed}\n" + ) + LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n" - + def has_content(items): """Check if list has non-None, non-empty content without evaluating tensor booleans""" if not items: @@ -99,12 +109,13 @@ class WASComfyViewer: continue return True return False - + # Import parser system from .modules.parsers import parse_output, handle_all_inputs - + # Compute a hash of the current input content to detect changes import hashlib + def compute_input_hash(content_list): """Compute a hash of input content for change detection.""" if not content_list: @@ -117,21 +128,35 @@ class WASComfyViewer: combined += item_str if not combined: return "" - return hashlib.md5(combined.encode('utf-8', errors='replace')).hexdigest() - + return hashlib.md5(combined.encode("utf-8", errors="replace")).hexdigest() + current_input_hash = compute_input_hash(content) - + # Check view_state for parser output FIRST # Views store output in view_state with keys ending in "_output" (e.g., canvas_output) # BUT only use cached output if input hasn't changed (prevents stale results) if has_content(view_state): - state_str = to_string(view_state[0]) if len(view_state) == 1 else view_state[0] + state_str = ( + to_string(view_state[0]) if len(view_state) == 1 else view_state[0] + ) try: state_data = json.loads(state_str) if state_str else {} stored_input_hash = state_data.get("_input_hash", "") - - # Only use cached _output if input content hasn't changed - if current_input_hash and stored_input_hash == current_input_hash: + + # Use cached _output if: + # - No input content (UI views like OpenReel can produce output standalone) + # - Input content hasn't changed since the output was stored + use_cached = False + if not current_input_hash: + use_cached = True + elif stored_input_hash == current_input_hash: + use_cached = True + elif stored_input_hash and stored_input_hash != current_input_hash: + logger.info( + "[WAS Viewer] Input content changed, ignoring cached view_state output" + ) + + if use_cached: for key, value in state_data.items(): if key.endswith("_output") and value: parsed = parse_output(value, logger) @@ -140,32 +165,44 @@ class WASComfyViewer: "ui": { "text": (parsed["display_text"],), "source_content": (parsed["display_text"],), - "content_hash": (parsed["content_hash"],) + "content_hash": (parsed["content_hash"],), }, - "result": (parsed["output_values"],) + "result": (parsed["output_values"],), } - elif current_input_hash and stored_input_hash and stored_input_hash != current_input_hash: - logger.info("[WAS Viewer] Input content changed, ignoring cached view_state output") except json.JSONDecodeError: pass - + # Try input handlers (e.g., IMAGE tensors -> canvas view) # Use handle_all_inputs to support multi-view content (e.g., tensor can be canvas OR object view) input_handled = handle_all_inputs(content, logger) if input_handled: - logger.info(f"[WAS Viewer] Input handled by: {input_handled.get('parser_name', 'unknown')}") + logger.info( + f"[WAS Viewer] Input handled by: {input_handled.get('parser_name', 'unknown')}" + ) display_text = input_handled["display_content"] source_content = display_text content_hash = input_handled["content_hash"] output_values = input_handled["output_values"] elif has_content(manual_content): # Non-parser manual content (parsers already checked above) - combined = to_string(manual_content[0]) if len(manual_content) == 1 else LIST_SEPARATOR.join(to_string(m) for m in manual_content) - values = combined.split(LIST_SEPARATOR) if LIST_SEPARATOR in combined else [combined] + combined = ( + to_string(manual_content[0]) + if len(manual_content) == 1 + else LIST_SEPARATOR.join(to_string(m) for m in manual_content) + ) + values = ( + combined.split(LIST_SEPARATOR) + if LIST_SEPARATOR in combined + else [combined] + ) logger.info(f"[WAS Viewer] Using manual_content: {len(values)} items") display_text = LIST_SEPARATOR.join(values) - source_content = LIST_SEPARATOR.join(to_string(c) for c in content) if content else "" - content_hash = str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF) + source_content = ( + LIST_SEPARATOR.join(to_string(c) for c in content) if content else "" + ) + content_hash = ( + str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF) + ) output_values = [v for i, v in enumerate(values) if i not in excluded] if not output_values: output_values = [""] @@ -173,8 +210,12 @@ class WASComfyViewer: values = [to_string(c) for c in content] logger.info(f"[WAS Viewer] Using content input: {len(values)} items") display_text = LIST_SEPARATOR.join(values) - source_content = LIST_SEPARATOR.join(to_string(c) for c in content) if content else "" - content_hash = str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF) + source_content = ( + LIST_SEPARATOR.join(to_string(c) for c in content) if content else "" + ) + content_hash = ( + str(len(source_content)) + "_" + str(hash(source_content) & 0xFFFFFFFF) + ) output_values = [v for i, v in enumerate(values) if i not in excluded] if not output_values: output_values = [""] @@ -185,13 +226,21 @@ class WASComfyViewer: source_content = "" content_hash = "empty_0" output_values = [""] - - return {"ui": {"text": (display_text,), "source_content": (source_content,), "content_hash": (content_hash,), "input_hash": (current_input_hash,)}, "result": (output_values,)} + + return { + "ui": { + "text": (display_text,), + "source_content": (source_content,), + "content_hash": (content_hash,), + "input_hash": (current_input_hash,), + }, + "result": (output_values,), + } class WASCanvasComposeBatch: """Combines two image batches, padding all images with transparency to the largest size.""" - + @classmethod def INPUT_TYPES(cls): return { @@ -210,45 +259,47 @@ class WASCanvasComposeBatch: def run(self, images_a=None, images_b=None): import torch import logging - + logger = logging.getLogger("WAS.CanvasComposeBatch") all_images = [] - + def add_batch(batch): if batch is None: return - if hasattr(batch, 'shape'): + if hasattr(batch, "shape"): if len(batch.shape) == 4: for i in range(batch.shape[0]): all_images.append(batch[i]) elif len(batch.shape) == 3: all_images.append(batch) - + add_batch(images_a) add_batch(images_b) - + if len(all_images) == 0: return (torch.zeros((1, 64, 64, 4)),) - + max_h = max(img.shape[0] for img in all_images) max_w = max(img.shape[1] for img in all_images) - - logger.info(f"[WAS CanvasComposeBatch] Padding {len(all_images)} images to {max_w}x{max_h}") - + + logger.info( + f"[WAS CanvasComposeBatch] Padding {len(all_images)} images to {max_w}x{max_h}" + ) + padded_images = [] - + for img in all_images: h, w, c = img.shape padded = torch.zeros((max_h, max_w, 4), dtype=img.dtype, device=img.device) y_offset = (max_h - h) // 2 x_offset = (max_w - w) // 2 - padded[y_offset:y_offset+h, x_offset:x_offset+w, :c] = img - padded[y_offset:y_offset+h, x_offset:x_offset+w, 3] = 1.0 + padded[y_offset : y_offset + h, x_offset : x_offset + w, :c] = img + padded[y_offset : y_offset + h, x_offset : x_offset + w, 3] = 1.0 padded_images.append(padded) - + result = torch.stack(padded_images, dim=0) - + return (result,) @@ -265,7 +316,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { class NodeLoader: """Dynamically loads extension nodes from the ./nodes package.""" - + def __init__(self, package_name: str, prefix: str = "[WAS Viewer] "): self.package_name = package_name self.prefix = prefix @@ -282,9 +333,13 @@ class NodeLoader: self.timings[self.module_path(module)] = (elapsed, ok, err) if ok: NODE_CLASS_MAPPINGS.update(getattr(module, "NODE_CLASS_MAPPINGS", {})) - NODE_DISPLAY_NAME_MAPPINGS.update(getattr(module, "NODE_DISPLAY_NAME_MAPPINGS", {})) + NODE_DISPLAY_NAME_MAPPINGS.update( + getattr(module, "NODE_DISPLAY_NAME_MAPPINGS", {}) + ) - def import_module(self, fullname: str, package: str | None = None) -> tuple[object | None, bool]: + def import_module( + self, fullname: str, package: str | None = None + ) -> tuple[object | None, bool]: t0 = time.time() ok = True err = None @@ -300,9 +355,12 @@ class NodeLoader: self.record(mod, elapsed, ok, err) return mod, ok - def import_file(self, filepath: str, module_name: str) -> tuple[object | None, bool]: + def import_file( + self, filepath: str, module_name: str + ) -> tuple[object | None, bool]: """Load a .py file directly by path without requiring package structure.""" import importlib.util + t0 = time.time() ok = True err = None @@ -312,6 +370,7 @@ class NodeLoader: if spec and spec.loader: mod = importlib.util.module_from_spec(spec) import sys + sys.modules[module_name] = mod spec.loader.exec_module(mod) except Exception as e: @@ -326,41 +385,58 @@ class NodeLoader: def load_all(self) -> None: package_path = os.path.dirname(__file__) nodes_path = os.path.join(package_path, "nodes") - + if not os.path.isdir(nodes_path): return - + # Load .py files directly from nodes folder (no __init__.py required) for filename in os.listdir(nodes_path): if filename.endswith(".py") and not filename.startswith("_"): filepath = os.path.join(nodes_path, filename) module_name = f"{self.package_name}.nodes.{filename[:-3]}" self.import_file(filepath, module_name) - + # Walk subpackages if they exist (folders with __init__.py) for item in os.listdir(nodes_path): item_path = os.path.join(nodes_path, item) - if os.path.isdir(item_path) and os.path.isfile(os.path.join(item_path, "__init__.py")): - subpkg, ok = self.import_module(f".nodes.{item}", package=self.package_name) + if os.path.isdir(item_path) and os.path.isfile( + os.path.join(item_path, "__init__.py") + ): + subpkg, ok = self.import_module( + f".nodes.{item}", package=self.package_name + ) if ok and subpkg is not None: - for _, name, _ in pkgutil.walk_packages(subpkg.__path__, prefix=subpkg.__name__ + "."): + for _, name, _ in pkgutil.walk_packages( + subpkg.__path__, prefix=subpkg.__name__ + "." + ): self.import_module(name) - + # Log summary if self.timings: total = len(self.timings) ok_count = sum(1 for _, (_, success, _) in self.timings.items() if success) fail_count = total - ok_count ok_modules = ", ".join(p for p, (_, s, _) in self.timings.items() if s) - failed_modules = ", ".join(f"{p}: {e}" for p, (_, s, e) in self.timings.items() if not s) + failed_modules = ", ".join( + f"{p}: {e}" for p, (_, s, e) in self.timings.items() if not s + ) if ok_count > 0: - self.logger.info(f"{self.prefix}Loaded {ok_count}/{total} nodes: [{ok_modules}]") + self.logger.info( + f"{self.prefix}Loaded {ok_count}/{total} nodes: [{ok_modules}]" + ) if fail_count > 0: - self.logger.error(f"{self.prefix}Failed {fail_count}/{total} nodes: [{failed_modules}]") + self.logger.error( + f"{self.prefix}Failed {fail_count}/{total} nodes: [{failed_modules}]" + ) _loader = NodeLoader(package_name=__name__, prefix="[WAS Viewer] ") _loader.load_all() -__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] +# Load API routes from extensions +try: + from . import routes +except ImportError: + logger.info("[WAS Viewer] No routes directory found, skipping route loading") +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/extensions/README.md b/extensions/README.md index b33f1dc..06bd2ca 100644 --- a/extensions/README.md +++ b/extensions/README.md @@ -13,6 +13,8 @@ This guide covers how to create view extensions for ComfyUI_Viewer. Extensions c - [Complete Extension Example](#complete-extension-example) - [View Manifest Registration](#view-manifest-registration) - [Extension Architecture Overview](#extension-architecture-overview) + +### Simple Frontend Views (JavaScript + Parser) - [Adding a Frontend View](#adding-a-frontend-view) - [Quick Start](#quick-start) - [BaseView Interface](#baseview-interface) @@ -24,6 +26,20 @@ This guide covers how to create view extensions for ComfyUI_Viewer. Extensions c - [BaseParser Interface Reference](#baseparser-interface-reference) - [Parser + View Integration](#parser--view-integration) - [Multi-View Detection](#multi-view-detection) + +### Embedded Web Applications (Full React/Vue/Svelte Apps) +- [Embedding Full Web Applications](#embedding-full-web-applications) + - [Architecture Overview](#architecture-overview) + - [API Route Registration](#1-api-route-registration-python) + - [Frontend View Wrapper](#2-frontend-view-javascript) + - [PostMessage Communication](#3-communication-pattern-postmessage) + - [Theme Integration](#4-theme-integration) + - [Parser Integration](#5-parser-integration) + - [Complete Example: OpenReel](#complete-example-openreel-extension) + - [Best Practices](#best-practices) + - [Deployment](#deployment) + +### General - [Troubleshooting](#troubleshooting) --- @@ -537,6 +553,491 @@ This allows users to choose how they want to visualize the data: --- +## Embedding Full Web Applications + +For complex interactive tools that require a full web framework (React, Vue, Svelte, etc.), you can serve a complete web application from your extension using the `/app` functionality. This approach allows you to embed sophisticated applications (video editors, image galleries, drawing tools, etc.) directly into ComfyUI_Viewer. + +### Architecture Overview + +``` +Extension Package/ +├── apps/ +│ └── my_app/ # Built web app (HTML/JS/CSS) +│ ├── index.html +│ ├── assets/ +│ │ ├── index-abc123.js +│ │ └── index-def456.css +│ └── ... +├── routes/ +│ └── my_app_routes.py # API routes to serve the app +├── nodes/ +│ └── my_app_nodes.py # ComfyUI nodes (optional) +├── modules/ +│ └── parsers/ +│ └── my_app_parser.py # Handles input/output data flow +└── web/ + └── views/ + └── my_app.js # Minimal view that creates iframe +``` + +### Key Components + +#### 1. API Route Registration (Python) + +Create a routes file to register API endpoints for serving your built app: + +```python +# routes/my_app_routes.py +from aiohttp import web +from server import PromptServer +import os +import mimetypes +import logging + +logger = logging.getLogger("WAS.MyApp.Routes") + +def _get_my_app_dir(): + """Get path to the built app static files.""" + return os.path.join(os.path.dirname(__file__), "..", "apps", "my_app") + +@PromptServer.instance.routes.get('/was/my_app/app/{path:.*}') +async def serve_my_app(request): + """Serve the built app static files.""" + path = request.match_info.get('path', 'index.html') + if not path: + path = 'index.html' + + app_dir = _get_my_app_dir() + file_path = os.path.join(app_dir, path) + + # Security: Prevent path traversal + real_app_dir = os.path.realpath(app_dir) + real_file_path = os.path.realpath(file_path) + if not real_file_path.startswith(real_app_dir): + return web.json_response({'error': 'Access denied'}, status=403) + + if not os.path.exists(real_file_path) or not os.path.isfile(real_file_path): + return web.json_response({'error': 'File not found'}, status=404) + + # Determine content type + content_type, _ = mimetypes.guess_type(real_file_path) + if content_type is None: + content_type = 'application/octet-stream' + + # Ensure correct MIME types for web assets + if real_file_path.endswith('.js'): + content_type = 'application/javascript' + elif real_file_path.endswith('.css'): + content_type = 'text/css' + + with open(real_file_path, 'rb') as f: + data = f.read() + + return web.Response( + body=data, + content_type=content_type, + headers={'Content-Length': str(len(data))} + ) + +logger.info("[My App] API routes registered") +``` + +**Important Notes:** +- Routes are automatically discovered and loaded from the `routes/` directory at ComfyUI startup +- The `routes/` directory is separate from `nodes/` to keep API endpoints organized +- `nodes/` is only for ComfyUI nodes that appear in the graph + +If your app uses a bundler (Vite, Webpack, etc.), configure the **base path** to match your API route: + +```javascript +// vite.config.ts +export default { + base: '/was/my_app/app/', // Must match your API route + // ... +} +``` + +This ensures all asset paths (JS, CSS, images) resolve correctly when served from ComfyUI. + +#### 2. Frontend View (JavaScript) + +Create a minimal view that embeds your app in an iframe: + +```javascript +// web/views/my_app.js +import { BaseView } from "./base_view.js"; + +class MyAppView extends BaseView { + static id = "my_app"; + static displayName = "My App"; + static priority = 100; + static isUI = true; // Hide Edit/Clear/Download buttons + + static INPUT_MARKER = "$WAS_MY_APP$"; + static OUTPUT_MARKER = "$WAS_MY_APP_OUTPUT$"; + + static detect(content) { + if (typeof content === "string" && content.startsWith(this.INPUT_MARKER)) { + return 100; + } + return 0; + } + + static getContentMarker() { + return this.INPUT_MARKER; + } + + /** + * Build the app URL with embedding params and theme + */ + static _buildAppUrl(theme) { + const origin = window.location.origin; + const params = new URLSearchParams({ embedded: 'true' }); + + // Pass ComfyUI theme to the app + if (theme) { + if (theme.bg) params.set('theme_bg', theme.bg); + if (theme.fg) params.set('theme_fg', theme.fg); + if (theme.accent) params.set('theme_accent', theme.accent); + // ... add more theme tokens as needed + } + + return `${origin}/was/my_app/app/index.html?${params.toString()}`; + } + + static render(content, theme) { + const appUrl = this._buildAppUrl(theme); + + return ` +
+ +
+ `; + } + + static usesBaseStyles() { + return false; // App provides its own styles + } + + /** + * Handle messages from the embedded app + */ + static getMessageTypes() { + return ["my-app-output"]; + } + + static handleMessage(messageType, data, node, app, iframeSource) { + if (messageType !== "my-app-output") return false; + + const outputString = this.OUTPUT_MARKER + JSON.stringify(data); + + const viewStateWidget = node.widgets?.find(w => w.name === "view_state"); + if (viewStateWidget) { + const viewState = JSON.parse(viewStateWidget.value || "{}"); + viewState.my_app_output = outputString; + viewStateWidget.value = JSON.stringify(viewState); + node.setDirtyCanvas?.(true, true); + return true; + } + return false; + } +} + +export default MyAppView; +``` + +#### 3. Communication Pattern (PostMessage) + +Your embedded app communicates with ComfyUI using the PostMessage API: + +**Sending data to ComfyUI:** +```javascript +// Inside your React/Vue/etc app +window.parent.postMessage({ + type: 'my-app-output', + data: { result: 'some data' } +}, '*'); +``` + +**Receiving data from ComfyUI:** +```javascript +// Inside your React/Vue/etc app +useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'comfyui-import-data') { + // Handle data from ComfyUI workflow + const data = event.data.data; + // Update your app state... + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); +}, []); + +// Notify parent that app is ready +useEffect(() => { + if (window.parent !== window) { + window.parent.postMessage({ type: 'my-app-ready' }, '*'); + } +}, []); +``` + +#### 4. Theme Integration + +To match ComfyUI's theme, parse the URL parameters in your app: + +```javascript +// Inside your React/Vue/etc app +const params = new URLSearchParams(window.location.search); +const isEmbedded = params.get('embedded') === 'true'; + +if (isEmbedded) { + // Apply ComfyUI theme + const themeBg = params.get('theme_bg') || '#1a1a1a'; + const themeFg = params.get('theme_fg') || '#cccccc'; + const themeAccent = params.get('theme_accent') || '#4a9eff'; + + // Set CSS variables or update your theme store + document.documentElement.style.setProperty('--bg-color', themeBg); + document.documentElement.style.setProperty('--text-color', themeFg); + document.documentElement.style.setProperty('--accent-color', themeAccent); +} +``` + +#### 5. Parser Integration + +Your parser handles the data flow between ComfyUI nodes and your app: + +```python +# modules/parsers/my_app_parser.py +import json +from .base_parser import BaseParser + +class MyAppParser(BaseParser): + PARSER_NAME = "my_app" + PARSER_PRIORITY = 100 + INPUT_MARKER = "$WAS_MY_APP$" + OUTPUT_MARKER = "$WAS_MY_APP_OUTPUT$" + + @classmethod + def detect_input(cls, content) -> bool: + # Detect your input data type + return isinstance(content, str) and content.startswith("MY_APP_DATA:") + + @classmethod + def handle_input(cls, content, logger=None) -> dict: + # Prepare data for the app + data = {"input": content} + return { + "display_content": cls.INPUT_MARKER + json.dumps(data), + "output_values": [content], + "content_hash": f"my_app_{hash(content) & 0xFFFFFFFF}", + } + + @classmethod + def detect_output(cls, content: str) -> bool: + return isinstance(content, str) and content.startswith(cls.OUTPUT_MARKER) + + @classmethod + def parse_output(cls, content: str, logger=None) -> dict: + # Parse output from the app + json_str = content[len(cls.OUTPUT_MARKER):] + data = json.loads(json_str) + result = data.get("result", "") + + return { + "output_values": [result], + "display_text": f"Result: {result}", + "content_hash": f"my_app_out_{hash(result) & 0xFFFFFFFF}", + } +``` + +### Complete Workflow: Building and Deploying Your App + +#### Step 1: Develop Your Web Application + +Build your application using your preferred framework (React, Vue, Svelte, etc.): + +```bash +# Example with Vite + React +npm create vite@latest my-app -- --template react +cd my-app +npm install + +# Configure base path in vite.config.ts +export default { + base: '/was/my_app/app/', // Must match your API route + build: { + outDir: 'dist', + assetsDir: 'assets', + } +} + +# Build for production +npm run build +``` + +Your `dist/` folder will contain: +``` +dist/ +├── index.html +├── assets/ +│ ├── index-abc123.js +│ ├── index-def456.css +│ └── ... +└── ... +``` + +#### Step 2: Create Your Extension Package + +Create the extension folder structure: + +``` +ComfyUI_Viewer_MyApp_Extension/ +├── apps/ +│ └── my_app/ # Copy your built app here +│ ├── index.html +│ └── assets/ +├── nodes/ +│ └── my_app_nodes.py # API routes + ComfyUI nodes +├── modules/ +│ └── parsers/ +│ └── my_app_parser.py # Input/output handling +├── web/ +│ └── views/ +│ └── my_app.js # Iframe wrapper view +└── README.md +``` + +**Copy your built app:** +```bash +cp -r my-app/dist/* ComfyUI_Viewer_MyApp_Extension/apps/my_app/ +``` + +#### Step 3: Create ComfyUI Nodes (Optional) + +Add custom nodes to generate input data for your app: + +```python +# nodes/my_app_nodes.py +from aiohttp import web +from server import PromptServer +import os +import mimetypes + +# ... (API route code from section 1 above) + +# Optional: Add ComfyUI nodes +class MyAppInputNode: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "data": ("STRING", {"default": ""}), + } + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "process" + CATEGORY = "WAS/View" + + def process(self, data): + # Prepare data for your app + return (f"MY_APP_DATA:{data}",) + +NODE_CLASS_MAPPINGS = { + "CV My App Input": MyAppInputNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "CV My App Input": "CV My App Input", +} +``` + +#### Step 4: Test Locally + +1. **Copy extension to ComfyUI:** + ```bash + cp -r ComfyUI_Viewer_MyApp_Extension ComfyUI/custom_nodes/ComfyUI_Viewer/extensions/ + ``` + +2. **Restart ComfyUI** - The extension installer will: + - Copy files to correct locations + - Register your nodes + - Make your app available at `/was/my_app/app/` + +3. **Test in workflow:** + - Add your input node (if created) + - Connect to Content Viewer + - Run workflow - your app should load in the viewer + +#### Step 5: Package for Distribution + +Create a GitHub repository with your extension: + +```bash +cd ComfyUI_Viewer_MyApp_Extension +git init +git add . +git commit -m "Initial commit" +git remote add origin https://github.com/YourUsername/ComfyUI_Viewer_MyApp_Extension +git push -u origin main +``` + +**Users install by:** +1. Downloading ZIP from GitHub +2. Placing in `ComfyUI/custom_nodes/ComfyUI_Viewer/extensions/` +3. Restarting ComfyUI + +#### Step 6: Update Your App + +When you update your app: + +```bash +# Rebuild your app +cd my-app +npm run build + +# Copy to extension +cp -r dist/* ../ComfyUI_Viewer_MyApp_Extension/apps/my_app/ + +# Commit and push +cd ../ComfyUI_Viewer_MyApp_Extension +git add apps/my_app/ +git commit -m "Update app to v1.1" +git push +``` + +Users get updates by re-downloading the ZIP and replacing their installation. + +### Best Practices + +1. **Security**: Always validate and sanitize paths in API routes to prevent path traversal attacks +2. **MIME Types**: Ensure correct Content-Type headers for JS/CSS/WASM files +3. **Base Path**: Configure your bundler's base path to match your API route +4. **Theme Sync**: Pass ComfyUI theme via URL params for seamless integration +5. **Embedded Detection**: Use `?embedded=true` to conditionally show/hide UI elements +6. **PostMessage**: Use typed messages with clear naming conventions (e.g., `my-app-output`) +7. **State Persistence**: Store output in `view_state` widget for workflow persistence +8. **Error Handling**: Gracefully handle iframe load failures and communication errors + +### Deployment + +When distributing your extension: + +1. **Build your app** using your bundler (Vite, Webpack, etc.) +2. **Copy built files** to `apps/my_app/` in your extension package +3. **Include in zip** - Users download and extract to `ComfyUI_Viewer/extensions/` +4. **Auto-install** - Extension installer copies files to correct locations + +--- + ## Troubleshooting ### Content not updating diff --git a/modules/parsers/__init__.py b/modules/parsers/__init__.py index a0895cb..7c3e292 100644 --- a/modules/parsers/__init__.py +++ b/modules/parsers/__init__.py @@ -23,116 +23,122 @@ _loaded = False def _load_parser_from_file(filepath: str, source_name: str = "local"): """Load parser classes from a specific file path.""" import importlib.util - + loaded = [] filename = os.path.basename(filepath) module_name = filename[:-3] - + try: spec = importlib.util.spec_from_file_location(module_name, filepath) if spec is None or spec.loader is None: return loaded - + module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - + for name, obj in inspect.getmembers(module, inspect.isclass): if obj is BaseParser: continue if not issubclass(obj, BaseParser): continue - + parser_info = { - 'name': obj.PARSER_NAME, - 'priority': obj.PARSER_PRIORITY, - 'class': obj, - 'detect_input': obj.detect_input, - 'handle_input': obj.handle_input, - 'detect_output': obj.detect_output, - 'parse_output': obj.parse_output, + "name": obj.PARSER_NAME, + "priority": obj.PARSER_PRIORITY, + "class": obj, + "detect_input": obj.detect_input, + "handle_input": obj.handle_input, + "detect_output": obj.detect_output, + "parse_output": obj.parse_output, } - + loaded.append(parser_info) - logger.info(f"[Parsers] Loaded parser: {obj.PARSER_NAME} (priority {obj.PARSER_PRIORITY}) from {source_name}") - + logger.info( + f"[Parsers] Loaded parser: {obj.PARSER_NAME} (priority {obj.PARSER_PRIORITY}) from {source_name}" + ) + except Exception as e: logger.error(f"[Parsers] Failed to load {filename} from {source_name}: {e}") - + return loaded def load_parsers(): """Load all parser classes from this directory and development extensions.""" global _parsers, _loaded - + if _loaded: return _parsers - + parsers_dir = os.path.dirname(__file__) package_name = __name__ - + # Load parsers from this directory (installed parsers) for filename in os.listdir(parsers_dir): if not filename.endswith("_parser.py") or filename == "base_parser.py": continue - + module_name = filename[:-3] full_module_name = f"{package_name}.{module_name}" - + try: module = importlib.import_module(full_module_name) - + for name, obj in inspect.getmembers(module, inspect.isclass): if obj is BaseParser: continue if not issubclass(obj, BaseParser): continue - + parser_info = { - 'name': obj.PARSER_NAME, - 'priority': obj.PARSER_PRIORITY, - 'class': obj, - 'detect_input': obj.detect_input, - 'handle_input': obj.handle_input, - 'detect_output': obj.detect_output, - 'parse_output': obj.parse_output, + "name": obj.PARSER_NAME, + "priority": obj.PARSER_PRIORITY, + "class": obj, + "detect_input": obj.detect_input, + "handle_input": obj.handle_input, + "detect_output": obj.detect_output, + "parse_output": obj.parse_output, } - + _parsers.append(parser_info) - logger.info(f"[Parsers] Loaded parser: {obj.PARSER_NAME} (priority {obj.PARSER_PRIORITY})") - + logger.info( + f"[Parsers] Loaded parser: {obj.PARSER_NAME} (priority {obj.PARSER_PRIORITY})" + ) + except Exception as e: logger.error(f"[Parsers] Failed to load {filename}: {e}") - + # Development mode: Load parsers from sibling ComfyUI_Viewer_* extension directories # This allows testing extensions without creating/installing zip files - workspace_dir = os.path.dirname(os.path.dirname(os.path.dirname(parsers_dir))) # Go up to workspace + workspace_dir = os.path.dirname( + os.path.dirname(os.path.dirname(parsers_dir)) + ) # Go up to workspace if os.path.isdir(workspace_dir): - loaded_names = {p['name'] for p in _parsers} - + loaded_names = {p["name"] for p in _parsers} + for entry in os.listdir(workspace_dir): if not entry.startswith("ComfyUI_Viewer_"): continue - + ext_parsers_dir = os.path.join(workspace_dir, entry, "modules", "parsers") if not os.path.isdir(ext_parsers_dir): continue - + for filename in os.listdir(ext_parsers_dir): if not filename.endswith("_parser.py") or filename == "base_parser.py": continue - + filepath = os.path.join(ext_parsers_dir, filename) ext_parsers = _load_parser_from_file(filepath, f"dev:{entry}") - + for parser_info in ext_parsers: - if parser_info['name'] not in loaded_names: + if parser_info["name"] not in loaded_names: _parsers.append(parser_info) - loaded_names.add(parser_info['name']) - - _parsers.sort(key=lambda p: p['priority'], reverse=True) + loaded_names.add(parser_info["name"]) + + _parsers.sort(key=lambda p: p["priority"], reverse=True) _loaded = True - + return _parsers @@ -146,23 +152,23 @@ def get_parsers(): def get_parser_by_name(name: str): """Get a specific parser by name.""" for parser in get_parsers(): - if parser['name'] == name: + if parser["name"] == name: return parser return None def get_all_parser_names(): """Get list of all loaded parser names.""" - return [p['name'] for p in get_parsers()] + return [p["name"] for p in get_parsers()] def find_input_handler(content): """Find the first parser that can handle this input content.""" for parser in get_parsers(): - if not parser['detect_input']: + if not parser["detect_input"]: continue try: - if parser['detect_input'](content): + if parser["detect_input"](content): return parser except Exception as e: logger.error(f"[Parsers] Error in {parser['name']}.detect_input(): {e}") @@ -172,10 +178,10 @@ def find_input_handler(content): def find_output_parser(content: str): """Find the first parser that can parse this output content.""" for parser in get_parsers(): - if not parser['detect_output']: + if not parser["detect_output"]: continue try: - if parser['detect_output'](content): + if parser["detect_output"](content): return parser except Exception as e: logger.error(f"[Parsers] Error in {parser['name']}.detect_output(): {e}") @@ -185,7 +191,7 @@ def find_output_parser(content: str): def handle_input(content, logger=None): """ Try to handle input content using available parsers. - + Returns: dict with keys: display_content, output_values, content_hash or None if no parser matched @@ -193,11 +199,11 @@ def handle_input(content, logger=None): parser = find_input_handler(content) if parser is None: return None - + try: - result = parser['handle_input'](content, logger) + result = parser["handle_input"](content, logger) if result: - result['parser_name'] = parser['name'] + result["parser_name"] = parser["name"] return result except Exception as e: if logger: @@ -212,10 +218,10 @@ def find_all_input_handlers(content): """Find ALL parsers that can handle this input content.""" handlers = [] for parser in get_parsers(): - if not parser['detect_input']: + if not parser["detect_input"]: continue try: - if parser['detect_input'](content): + if parser["detect_input"](content): handlers.append(parser) except Exception as e: logger.error(f"[Parsers] Error in {parser['name']}.detect_input(): {e}") @@ -226,88 +232,88 @@ def handle_all_inputs(content, logger=None): """ Try to handle input content using ALL matching parsers. Returns multi-view payload if multiple parsers match. - + Returns: dict with keys: - If single match: display_content, output_values, content_hash, parser_name - - If multi match: display_content (with MULTIVIEW_MARKER), output_values, + - If multi match: display_content (with MULTIVIEW_MARKER), output_values, content_hash, views (list of view data) or None if no parser matched """ import json - + handlers = find_all_input_handlers(content) - + if not handlers: return None - + # Single handler - return as before if len(handlers) == 1: parser = handlers[0] try: - result = parser['handle_input'](content, logger) + result = parser["handle_input"](content, logger) if result: - result['parser_name'] = parser['name'] + result["parser_name"] = parser["name"] return result except Exception as e: if logger: logger.error(f"[Parsers] Error in {parser['name']}.handle_input(): {e}") return None - + # Multiple handlers - create multi-view payload views = [] output_values = None - + for parser in handlers: try: - result = parser['handle_input'](content, logger) + result = parser["handle_input"](content, logger) if result: view_data = { - "name": parser['name'], - "priority": parser['priority'], - "display_content": result.get('display_content', ''), - "content_hash": result.get('content_hash', ''), + "name": parser["name"], + "priority": parser["priority"], + "display_content": result.get("display_content", ""), + "content_hash": result.get("content_hash", ""), } views.append(view_data) - + # Use output_values from highest priority parser if output_values is None: - output_values = result.get('output_values', []) + output_values = result.get("output_values", []) except Exception as e: if logger: logger.error(f"[Parsers] Error in {parser['name']}.handle_input(): {e}") - + if not views: return None - + # Sort by priority (highest first) - first view is default - views.sort(key=lambda v: v['priority'], reverse=True) - + views.sort(key=lambda v: v["priority"], reverse=True) + # Create multi-view payload multiview_data = { "type": "multiview", - "default_view": views[0]['name'], + "default_view": views[0]["name"], "views": views, } - + if logger: - view_names = [v['name'] for v in views] + view_names = [v["name"] for v in views] logger.info(f"[Parsers] Multi-view content detected: {view_names}") - + return { "display_content": MULTIVIEW_MARKER + json.dumps(multiview_data), "output_values": output_values, "content_hash": f"multiview_{len(views)}_{views[0]['content_hash']}", "parser_name": "multiview", "is_multiview": True, - "available_views": [v['name'] for v in views], + "available_views": [v["name"] for v in views], } def parse_output(content: str, logger=None): """ Try to parse output content using available parsers. - + Returns: dict with keys: output_values, display_text, content_hash or None if no parser matched @@ -315,11 +321,11 @@ def parse_output(content: str, logger=None): parser = find_output_parser(content) if parser is None: return None - + try: - result = parser['parse_output'](content, logger) + result = parser["parse_output"](content, logger) if result: - result['parser_name'] = parser['name'] + result["parser_name"] = parser["name"] return result except Exception as e: if logger: @@ -332,8 +338,8 @@ def find_state_parser(state_data: dict): if not isinstance(state_data, dict): return None for parser in get_parsers(): - parser_class = parser['class'] - if not hasattr(parser_class, 'detect_state'): + parser_class = parser["class"] + if not hasattr(parser_class, "detect_state"): continue try: if parser_class.detect_state(state_data): @@ -346,16 +352,16 @@ def find_state_parser(state_data: dict): def parse_state(state_data, logger=None): """ Try to parse state data using available parsers. - + Args: state_data: dict or JSON string of state data logger: Optional logger - + Returns: dict with parser-specific keys, or None if no parser matched """ import json - + if isinstance(state_data, str): if not state_data or state_data == "{}": return None @@ -363,7 +369,7 @@ def parse_state(state_data, logger=None): state_data = json.loads(state_data) except json.JSONDecodeError: return None - + if isinstance(state_data, list): state_data = state_data[0] if state_data else None if isinstance(state_data, str): @@ -371,18 +377,18 @@ def parse_state(state_data, logger=None): state_data = json.loads(state_data) except json.JSONDecodeError: return None - + if not isinstance(state_data, dict): return None - + parser = find_state_parser(state_data) if parser is None: return None - + try: - result = parser['class'].parse_state(state_data, logger) + result = parser["class"].parse_state(state_data, logger) if result: - result['parser_name'] = parser['name'] + result["parser_name"] = parser["name"] return result except Exception as e: if logger: @@ -393,21 +399,23 @@ def parse_state(state_data, logger=None): def find_display_handler(content): """Find the first parser that can prepare display content for this input.""" for parser in get_parsers(): - parser_class = parser['class'] - if not hasattr(parser_class, 'detect_display_content'): + parser_class = parser["class"] + if not hasattr(parser_class, "detect_display_content"): continue try: if parser_class.detect_display_content(content): return parser except Exception as e: - logger.error(f"[Parsers] Error in {parser['name']}.detect_display_content(): {e}") + logger.error( + f"[Parsers] Error in {parser['name']}.detect_display_content(): {e}" + ) return None def prepare_display(content, logger=None): """ Try to prepare display content using available parsers. - + Returns: dict with keys: display_content, content_hash, count (optional) or None if no parser matched @@ -415,11 +423,11 @@ def prepare_display(content, logger=None): parser = find_display_handler(content) if parser is None: return None - + try: - result = parser['class'].prepare_display(content, logger) + result = parser["class"].prepare_display(content, logger) if result: - result['parser_name'] = parser['name'] + result["parser_name"] = parser["name"] return result except Exception as e: if logger: @@ -430,18 +438,18 @@ def prepare_display(content, logger=None): def get_default_outputs(content, output_types: list, logger=None): """ Try to get default outputs using available parsers. - + Args: content: Input content output_types: List of expected output type names (e.g., ["IMAGE", "MASK"]) logger: Optional logger - + Returns: tuple of default values, or None if no parser matched """ for parser in get_parsers(): - parser_class = parser['class'] - if not hasattr(parser_class, 'get_default_outputs'): + parser_class = parser["class"] + if not hasattr(parser_class, "get_default_outputs"): continue try: result = parser_class.get_default_outputs(content, output_types, logger) @@ -449,6 +457,7 @@ def get_default_outputs(content, output_types: list, logger=None): return result except Exception as e: if logger: - logger.error(f"[Parsers] Error in {parser['name']}.get_default_outputs(): {e}") + logger.error( + f"[Parsers] Error in {parser['name']}.get_default_outputs(): {e}" + ) return None - diff --git a/modules/parsers/ansi_parser.py b/modules/parsers/ansi_parser.py index 1c9c0cc..914d1e0 100644 --- a/modules/parsers/ansi_parser.py +++ b/modules/parsers/ansi_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class AnsiParser(BaseParser): """ANSI parser for terminal output with ANSI escape codes.""" - + PARSER_NAME = "ansi" PARSER_PRIORITY = 10 diff --git a/modules/parsers/base_parser.py b/modules/parsers/base_parser.py index 5a441e3..aa94dbd 100644 --- a/modules/parsers/base_parser.py +++ b/modules/parsers/base_parser.py @@ -19,30 +19,30 @@ from abc import ABC class BaseParser(ABC): """ Abstract base class for all view parsers. - + Subclasses must define PARSER_NAME. Override methods as needed. Default implementations return False/None for passthrough behavior. """ - + PARSER_NAME: str = "base" PARSER_PRIORITY: int = 0 OUTPUT_MARKER: str = None - + @classmethod def detect_input(cls, content) -> bool: """ Check if this parser should handle the input content. - + Override to detect specific content types (e.g., IMAGE tensors). Return False for passthrough parsers. """ return False - + @classmethod def handle_input(cls, content, logger=None) -> dict: """ Process input content for display. - + Returns: dict with keys: - display_content: str - Content for frontend @@ -51,23 +51,23 @@ class BaseParser(ABC): or None if not handling """ return None - + @classmethod def detect_output(cls, content: str) -> bool: """ Check if content contains this parser's output marker. - + Override if parser converts frontend output to backend types. """ if cls.OUTPUT_MARKER and isinstance(content, str): return content.startswith(cls.OUTPUT_MARKER) return False - + @classmethod def parse_output(cls, content: str, logger=None) -> dict: """ Parse output content and convert to backend types. - + Returns: dict with keys: - output_values: list - Converted values @@ -76,43 +76,43 @@ class BaseParser(ABC): or None if not handling """ return None - + @classmethod def detect_state(cls, state_data: dict) -> bool: """ Check if this parser should handle the given state data. - + Override to detect specific state formats (e.g., canvas state with dataUrl). Return False for parsers that don't handle state. """ return False - + @classmethod def parse_state(cls, state_data: dict, logger=None) -> dict: """ Parse state data from frontend and convert to backend types. - + Returns: dict with parser-specific keys (e.g., image, mask for canvas) or None if not handling """ return None - + @classmethod def detect_display_content(cls, content) -> bool: """ Check if this parser should prepare display content for the given input. - + Override to detect specific content types for display preparation. Return False for parsers that don't handle display preparation. """ return False - + @classmethod def prepare_display(cls, content, logger=None) -> dict: """ Prepare content for frontend display. - + Returns: dict with keys: - display_content: str - JSON or content for frontend @@ -121,17 +121,17 @@ class BaseParser(ABC): or None if not handling """ return None - + @classmethod def get_default_outputs(cls, content, output_types: list, logger=None) -> tuple: """ Get default output values based on input content and expected output types. - + Args: content: Input content output_types: List of expected output type names (e.g., ["IMAGE", "MASK"]) logger: Optional logger - + Returns: tuple of default values matching output_types, or None if not handling """ diff --git a/modules/parsers/canvas_parser.py b/modules/parsers/canvas_parser.py index 34b2578..bf87849 100644 --- a/modules/parsers/canvas_parser.py +++ b/modules/parsers/canvas_parser.py @@ -19,204 +19,204 @@ from .base_parser import BaseParser class CanvasParser(BaseParser): """Canvas parser for IMAGE tensor input and composite output.""" - + PARSER_NAME = "canvas" PARSER_PRIORITY = 100 # High priority - check before generic parsers - + # Markers for content identification CANVAS_MARKER = "$WAS_CANVAS$" OUTPUT_MARKER = "$WAS_CANVAS_OUTPUT$" CANVAS_TYPE = "canvas_composer" - + @classmethod def detect_input(cls, content) -> bool: """Check if content contains IMAGE tensors that should be displayed in canvas view.""" if content is None: return False - + items = content if isinstance(content, (list, tuple)) else [content] - + for item in items: if cls._is_image_tensor(item): return True return False - + @classmethod def handle_input(cls, content, logger=None) -> dict: """ Process IMAGE tensors and prepare them for canvas view display. - + Saves tensors as temp PNG files and returns canvas marker JSON. """ - + items = content if isinstance(content, (list, tuple)) else [content] - + session_id = str(uuid.uuid4())[:8] image_files = [] - + for item in items: if cls._is_image_tensor(item): files = cls._tensor_to_input_files(item, session_id, logger) image_files.extend(files) - + if not image_files: return None - + canvas_data = { "type": "canvas_composer", "images": image_files, "session_id": session_id, "count": len(image_files), } - + display_content = cls.CANVAS_MARKER + json.dumps(canvas_data) content_hash = f"canvas_{session_id}_{len(image_files)}" - + if logger: - logger.info(f"[Canvas Parser] Processed {len(image_files)} images for canvas view") - + logger.info( + f"[Canvas Parser] Processed {len(image_files)} images for canvas view" + ) + return { "display_content": display_content, "output_values": list(items), "content_hash": content_hash, } - + @classmethod def detect_output(cls, content: str) -> bool: """Check if content is canvas composite output (base64 image).""" if not isinstance(content, str): return False return content.startswith(cls.OUTPUT_MARKER) - + @classmethod def parse_output(cls, content: str, logger=None) -> dict: """Parse canvas composite output and convert to IMAGE tensor.""" import torch import numpy as np from PIL import Image - - base64_data = content[len(cls.OUTPUT_MARKER):] - + + base64_data = content[len(cls.OUTPUT_MARKER) :] + if base64_data.startswith("data:"): base64_data = base64_data.split(",", 1)[1] - + try: image_bytes = base64.b64decode(base64_data) pil_img = Image.open(io.BytesIO(image_bytes)).convert("RGBA") - + img_array = np.array(pil_img).astype(np.float32) / 255.0 img_tensor = torch.from_numpy(img_array).unsqueeze(0) - + if logger: - logger.info(f"[Canvas Parser] Converted composite to IMAGE tensor: {img_tensor.shape}") - + logger.info( + f"[Canvas Parser] Converted composite to IMAGE tensor: {img_tensor.shape}" + ) + return { "output_values": [img_tensor], "display_text": f"Canvas Output: {pil_img.width}x{pil_img.height} RGBA", "content_hash": f"canvas_output_{pil_img.width}x{pil_img.height}_{hash(base64_data[:100]) & 0xFFFFFFFF}", } - + except Exception as e: if logger: logger.error(f"[Canvas Parser] Failed to convert composite: {e}") return None - + @staticmethod def _is_image_tensor(item) -> bool: """Check if item is an IMAGE tensor (torch tensor with shape [B,H,W,C] or [H,W,C]).""" if item is None: return False - if hasattr(item, 'shape') and hasattr(item, 'cpu'): + if hasattr(item, "shape") and hasattr(item, "cpu"): shape = item.shape if len(shape) == 4 and shape[-1] in (1, 3, 4): return True if len(shape) == 3 and shape[-1] in (1, 3, 4): return True return False - + @staticmethod def _tensor_to_input_files(tensor, session_id, logger=None): """Convert IMAGE tensor to input PNG files (persists across restarts).""" import numpy as np from PIL import Image import folder_paths - + input_dir = folder_paths.get_input_directory() subdir = f"was_viewer_{session_id}" full_subdir = os.path.join(input_dir, subdir) os.makedirs(full_subdir, exist_ok=True) - + files = [] - + if len(tensor.shape) == 3: tensor = tensor.unsqueeze(0) - + batch_size = tensor.shape[0] - + for idx in range(batch_size): img_array = tensor[idx].cpu().numpy() img_array = (img_array * 255).astype(np.uint8) - + if img_array.shape[-1] == 4: - pil_img = Image.fromarray(img_array, mode='RGBA') + pil_img = Image.fromarray(img_array, mode="RGBA") pil_img = CanvasParser._trim_transparency(pil_img) elif img_array.shape[-1] == 3: - pil_img = Image.fromarray(img_array, mode='RGB') + pil_img = Image.fromarray(img_array, mode="RGB") else: pil_img = Image.fromarray(img_array) - + img_hash = hashlib.md5(img_array.tobytes()).hexdigest()[:12] filename = f"{idx:04d}_{img_hash}.png" filepath = os.path.join(full_subdir, filename) - + if not os.path.exists(filepath): - pil_img.save(filepath, format='PNG') + pil_img.save(filepath, format="PNG") if logger: logger.debug(f"[Canvas Parser] Saved: {filepath}") - - files.append({ - "filename": filename, - "subfolder": subdir, - "type": "input" - }) - + + files.append({"filename": filename, "subfolder": subdir, "type": "input"}) + return files - + @staticmethod def _trim_transparency(pil_img, padding=0): """Trim transparent pixels from image edges.""" import numpy as np - - if pil_img.mode != 'RGBA': + + if pil_img.mode != "RGBA": return pil_img - + alpha = np.array(pil_img.split()[-1]) rows = np.any(alpha > 0, axis=1) cols = np.any(alpha > 0, axis=0) - + if not rows.any() or not cols.any(): return pil_img - + rmin, rmax = np.where(rows)[0][[0, -1]] cmin, cmax = np.where(cols)[0][[0, -1]] - + rmin = max(0, rmin - padding) rmax = min(pil_img.height - 1, rmax + padding) cmin = max(0, cmin - padding) cmax = min(pil_img.width - 1, cmax + padding) - + return pil_img.crop((cmin, rmin, cmax + 1, rmax + 1)) - + @classmethod def detect_state(cls, state_data: dict) -> bool: """Check if state data contains canvas dataUrl.""" return isinstance(state_data, dict) and "dataUrl" in state_data - + @classmethod def parse_state(cls, state_data: dict, logger=None) -> dict: """ Parse canvas state containing composed image dataUrl. - + Returns: dict with keys: image (IMAGE tensor), mask (MASK tensor), width, height, display_content (JSON for UI) @@ -225,45 +225,47 @@ class CanvasParser(BaseParser): import torch import numpy as np from PIL import Image - + if not isinstance(state_data, dict) or "dataUrl" not in state_data: return None - + try: data_url = state_data["dataUrl"] if "," in data_url: b64_data = data_url.split(",", 1)[1] else: b64_data = data_url - + img_bytes = base64.b64decode(b64_data) pil_img = Image.open(io.BytesIO(img_bytes)) - - if pil_img.mode == 'RGBA': + + if pil_img.mode == "RGBA": alpha_channel = pil_img.split()[3] alpha_array = np.array(alpha_channel).astype(np.float32) / 255.0 alpha_mask = torch.from_numpy(alpha_array).unsqueeze(0) - rgb_img = Image.new('RGB', pil_img.size, (0, 0, 0)) + rgb_img = Image.new("RGB", pil_img.size, (0, 0, 0)) rgb_img.paste(pil_img, mask=alpha_channel) pil_img = rgb_img else: alpha_mask = torch.ones((1, pil_img.height, pil_img.width)) - if pil_img.mode != 'RGB': - pil_img = pil_img.convert('RGB') - + if pil_img.mode != "RGB": + pil_img = pil_img.convert("RGB") + img_array = np.array(pil_img).astype(np.float32) / 255.0 composed_image = torch.from_numpy(img_array).unsqueeze(0) - + if logger: - logger.info(f"[Canvas Parser] Loaded composed image from state: {pil_img.width}x{pil_img.height}") - + logger.info( + f"[Canvas Parser] Loaded composed image from state: {pil_img.width}x{pil_img.height}" + ) + canvas_data = { "type": cls.CANVAS_TYPE, "images": [], "count": 0, "has_output": True, } - + return { "image": composed_image, "mask": alpha_mask, @@ -272,12 +274,12 @@ class CanvasParser(BaseParser): "display_content": json.dumps(canvas_data), "content_hash": "composed", } - + except Exception as e: if logger: logger.error(f"[Canvas Parser] Error decoding state: {e}") return None - + @classmethod def detect_display_content(cls, content) -> bool: """Check if content contains IMAGE tensors for display.""" @@ -288,83 +290,91 @@ class CanvasParser(BaseParser): if cls._is_image_tensor(item): return True return False - + @classmethod def prepare_display(cls, content, logger=None) -> dict: """ Convert IMAGE tensors to base64 data URLs for canvas display. - + Returns: dict with keys: display_content (JSON string), count, content_hash """ import numpy as np from PIL import Image - + images = content if isinstance(content, (list, tuple)) else [content] base64_images = [] - + try: for img_batch in images: if img_batch is None: continue - - if hasattr(img_batch, 'shape'): + + if hasattr(img_batch, "shape"): if len(img_batch.shape) == 4: for i in range(img_batch.shape[0]): - img_array = img_batch[i].cpu().numpy() if hasattr(img_batch[i], 'cpu') else img_batch[i] + img_array = ( + img_batch[i].cpu().numpy() + if hasattr(img_batch[i], "cpu") + else img_batch[i] + ) img_array = (img_array * 255).astype(np.uint8) pil_img = Image.fromarray(img_array) - + buffer = io.BytesIO() - pil_img.save(buffer, format='PNG') - b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + pil_img.save(buffer, format="PNG") + b64 = base64.b64encode(buffer.getvalue()).decode("utf-8") base64_images.append(f"data:image/png;base64,{b64}") elif len(img_batch.shape) == 3: - img_array = img_batch.cpu().numpy() if hasattr(img_batch, 'cpu') else img_batch + img_array = ( + img_batch.cpu().numpy() + if hasattr(img_batch, "cpu") + else img_batch + ) img_array = (img_array * 255).astype(np.uint8) pil_img = Image.fromarray(img_array) - + buffer = io.BytesIO() - pil_img.save(buffer, format='PNG') - b64 = base64.b64encode(buffer.getvalue()).decode('utf-8') + pil_img.save(buffer, format="PNG") + b64 = base64.b64encode(buffer.getvalue()).decode("utf-8") base64_images.append(f"data:image/png;base64,{b64}") except Exception as e: if logger: logger.error(f"[Canvas Parser] Error converting images: {e}") - + canvas_data = { "type": cls.CANVAS_TYPE, "images": base64_images, "count": len(base64_images), } - + if logger: logger.info(f"[Canvas Parser] Processed {len(base64_images)} images") - + return { "display_content": json.dumps(canvas_data), "count": len(base64_images), "content_hash": str(len(base64_images)), } - + @classmethod def get_default_outputs(cls, content, output_types: list, logger=None) -> tuple: """ Get default IMAGE and MASK outputs based on input content. - + Returns: tuple matching output_types, or None if not applicable """ import torch - + if not output_types or set(output_types) != {"IMAGE", "MASK"}: return None - + images = content if isinstance(content, (list, tuple)) else [content] - + if images and len(images) > 0 and images[0] is not None: output_image = images[0] - if hasattr(images[0], 'shape') and len(images[0].shape) >= 3: + if hasattr(images[0], "shape") and len(images[0].shape) >= 3: h, w = images[0].shape[1], images[0].shape[2] output_mask = torch.ones((1, h, w)) else: @@ -372,7 +382,7 @@ class CanvasParser(BaseParser): else: output_image = torch.zeros((1, 64, 64, 3)) output_mask = torch.ones((1, 64, 64)) - + if output_types[0] == "IMAGE": return (output_image, output_mask) else: diff --git a/modules/parsers/css_parser.py b/modules/parsers/css_parser.py index 9c77b25..fb12a02 100644 --- a/modules/parsers/css_parser.py +++ b/modules/parsers/css_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class CssParser(BaseParser): """CSS parser for CSS code display.""" - + PARSER_NAME = "css" PARSER_PRIORITY = 10 diff --git a/modules/parsers/csv_parser.py b/modules/parsers/csv_parser.py index 89d8b5e..e95e23d 100644 --- a/modules/parsers/csv_parser.py +++ b/modules/parsers/csv_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class CsvParser(BaseParser): """CSV parser for CSV content display.""" - + PARSER_NAME = "csv" PARSER_PRIORITY = 10 diff --git a/modules/parsers/html_parser.py b/modules/parsers/html_parser.py index e7e3b76..b6ef5b3 100644 --- a/modules/parsers/html_parser.py +++ b/modules/parsers/html_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class HtmlParser(BaseParser): """HTML parser for HTML content display.""" - + PARSER_NAME = "html" PARSER_PRIORITY = 10 diff --git a/modules/parsers/javascript_parser.py b/modules/parsers/javascript_parser.py index 951bc04..fba544e 100644 --- a/modules/parsers/javascript_parser.py +++ b/modules/parsers/javascript_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class JavaScriptParser(BaseParser): """JavaScript parser for JavaScript code display.""" - + PARSER_NAME = "javascript" PARSER_PRIORITY = 10 diff --git a/modules/parsers/json_parser.py b/modules/parsers/json_parser.py index ef56679..087b353 100644 --- a/modules/parsers/json_parser.py +++ b/modules/parsers/json_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class JsonParser(BaseParser): """JSON parser for JSON content display.""" - + PARSER_NAME = "json" PARSER_PRIORITY = 10 diff --git a/modules/parsers/markdown_parser.py b/modules/parsers/markdown_parser.py index 3013c24..85d4ffd 100644 --- a/modules/parsers/markdown_parser.py +++ b/modules/parsers/markdown_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class MarkdownParser(BaseParser): """Markdown parser for markdown content display.""" - + PARSER_NAME = "markdown" PARSER_PRIORITY = 10 diff --git a/modules/parsers/object_parser.py b/modules/parsers/object_parser.py index bc3b5ee..741b248 100644 --- a/modules/parsers/object_parser.py +++ b/modules/parsers/object_parser.py @@ -20,36 +20,36 @@ from .base_parser import BaseParser class ObjectParser(BaseParser): """Parser for generic Python objects with introspection and metrics.""" - + PARSER_NAME = "object" PARSER_PRIORITY = 5 # Low priority - fallback for unrecognized objects OUTPUT_MARKER = "$WAS_OBJECT$" - + # Size limits for serialization MAX_TENSOR_ELEMENTS_PREVIEW = 100 MAX_STRING_LENGTH = 500 MAX_LIST_ITEMS = 50 MAX_DICT_KEYS = 100 MAX_ATTR_VALUE_LENGTH = 200 - + @classmethod def detect_input(cls, content) -> bool: """Detect any non-None object that isn't handled by other parsers.""" if content is None: return False - + # Check if it's a list/tuple of items items = content if isinstance(content, (list, tuple)) else [content] - + for item in items: if item is None: continue # Accept any object type - this is our fallback parser if cls._is_introspectable(item): return True - + return False - + @classmethod def _is_introspectable(cls, obj) -> bool: """Check if object is worth introspecting (not a basic string/number).""" @@ -60,50 +60,50 @@ class ObjectParser(BaseParser): return False # Accept everything else return True - + @classmethod def handle_input(cls, content, logger=None) -> dict: """Process objects and generate metrics/serialization for display.""" items = content if isinstance(content, (list, tuple)) else [content] - + object_data = { "type": "object_viewer", "objects": [], "count": 0, } - + for idx, item in enumerate(items): if item is None: continue - + obj_info = cls._introspect_object(item, logger) if obj_info: obj_info["index"] = idx object_data["objects"].append(obj_info) - + object_data["count"] = len(object_data["objects"]) - + if object_data["count"] == 0: return None - + display_content = cls.OUTPUT_MARKER + json.dumps(object_data, default=str) content_hash = f"object_{len(object_data['objects'])}_{hash(str(content)[:100]) & 0xFFFFFFFF}" - + if logger: logger.info(f"[Object Parser] Processed {object_data['count']} objects") - + return { "display_content": display_content, "output_values": list(items), "content_hash": content_hash, } - + @classmethod def _introspect_object(cls, obj, logger=None) -> Optional[Dict]: """Generate full introspection data for an object.""" obj_type = type(obj).__name__ module = type(obj).__module__ - + result = { "type_name": obj_type, "module": module, @@ -115,82 +115,86 @@ class ObjectParser(BaseParser): "serialized": None, "source_info": None, } - + # Get category-specific data category = result["category"] - + if category == "tensor": result["metrics"] = cls._get_tensor_metrics(obj) result["spectral"] = cls._get_tensor_spectral(obj) result["serialized"] = cls._serialize_tensor(obj) - + elif category == "pil_image": result["metrics"] = cls._get_pil_metrics(obj) result["spectral"] = cls._get_pil_spectral(obj) result["serialized"] = cls._serialize_pil(obj) - + elif category == "numpy": result["metrics"] = cls._get_numpy_metrics(obj) result["spectral"] = cls._get_numpy_spectral(obj) result["serialized"] = cls._serialize_numpy(obj) - + elif category == "safetensors": result["metrics"] = cls._get_safetensors_metrics(obj) result["serialized"] = cls._serialize_safetensors(obj) - + elif category == "dict": result["metrics"] = cls._get_dict_metrics(obj) result["serialized"] = cls._serialize_dict(obj) - + elif category == "list": result["metrics"] = cls._get_list_metrics(obj) result["serialized"] = cls._serialize_list(obj) - + else: # Generic object result["attributes"] = cls._get_object_attributes(obj) result["metrics"] = cls._get_object_metrics(obj) result["source_info"] = cls._get_source_info(obj) result["serialized"] = cls._serialize_object(obj) - + return result - + @classmethod def _categorize_object(cls, obj) -> str: """Determine the category of an object.""" obj_type = type(obj).__name__ module = type(obj).__module__ - + # Check for tensor types - if hasattr(obj, 'shape') and hasattr(obj, 'dtype'): - if 'torch' in module: + if hasattr(obj, "shape") and hasattr(obj, "dtype"): + if "torch" in module: return "tensor" - if 'numpy' in module or obj_type == 'ndarray': + if "numpy" in module or obj_type == "ndarray": return "numpy" - + # Check for PIL Image - if 'PIL' in module or obj_type == 'Image': + if "PIL" in module or obj_type == "Image": return "pil_image" - + # Check for safetensors - if 'safetensors' in module or hasattr(obj, 'keys') and hasattr(obj, 'get_tensor'): + if ( + "safetensors" in module + or hasattr(obj, "keys") + and hasattr(obj, "get_tensor") + ): return "safetensors" - + # Basic collections if isinstance(obj, dict): return "dict" if isinstance(obj, (list, tuple)): return "list" - + return "object" - + # ========== TENSOR METHODS ========== - + @classmethod def _get_tensor_metrics(cls, tensor) -> Dict: """Get metrics for PyTorch tensor.""" try: - + metrics = { "shape": list(tensor.shape), "dtype": str(tensor.dtype), @@ -200,24 +204,24 @@ class ObjectParser(BaseParser): "requires_grad": bool(tensor.requires_grad), "is_contiguous": bool(tensor.is_contiguous()), } - + # Memory info metrics["memory_bytes"] = tensor.element_size() * tensor.numel() metrics["memory_human"] = cls._format_bytes(metrics["memory_bytes"]) - + # Statistical metrics (on CPU for safety) try: t = tensor.detach().float() - if t.device.type != 'cpu': + if t.device.type != "cpu": t = t.cpu() - + metrics["stats"] = { "min": float(t.min()), "max": float(t.max()), "mean": float(t.mean()), "std": float(t.std()), } - + # Check for image-like tensor if len(tensor.shape) >= 3: if tensor.shape[-1] in (1, 3, 4): # HWC format @@ -230,26 +234,26 @@ class ObjectParser(BaseParser): metrics["channels"] = int(tensor.shape[-3]) except Exception: pass - + return metrics except Exception as e: return {"error": str(e)} - + @classmethod def _get_tensor_spectral(cls, tensor) -> Optional[Dict]: """Generate spectral/histogram data for tensor.""" try: import torch - + t = tensor.detach().float() - if t.device.type != 'cpu': + if t.device.type != "cpu": t = t.cpu() - + # Check if it looks like an image tensor shape = tensor.shape is_image = False channels = 0 - + if len(shape) >= 3: if shape[-1] in (1, 3, 4): # HWC is_image = True @@ -263,7 +267,7 @@ class ObjectParser(BaseParser): if len(shape) == 4: t = t[0] t = t.permute(1, 2, 0) # Convert to HWC - + if not is_image: # Generate simple histogram for non-image tensor flat = t.flatten() @@ -273,39 +277,45 @@ class ObjectParser(BaseParser): "data": hist.tolist(), "bins": bin_edges.tolist(), } - + # Generate channel histograms for image spectral = { "type": "spectral", "channels": [], } - - channel_names = ["R", "G", "B", "A"][:channels] if channels <= 4 else [f"C{i}" for i in range(channels)] - + + channel_names = ( + ["R", "G", "B", "A"][:channels] + if channels <= 4 + else [f"C{i}" for i in range(channels)] + ) + for i in range(channels): channel = t[..., i].flatten() hist, bin_edges = torch.histogram(channel, bins=64, range=(0.0, 1.0)) - spectral["channels"].append({ - "name": channel_names[i], - "histogram": hist.tolist(), - "bins": bin_edges.tolist(), - }) - + spectral["channels"].append( + { + "name": channel_names[i], + "histogram": hist.tolist(), + "bins": bin_edges.tolist(), + } + ) + return spectral except Exception: return None - + @classmethod def _serialize_tensor(cls, tensor) -> str: """Create trimmed serialization of tensor.""" try: t = tensor.detach() - if t.device.type != 'cpu': + if t.device.type != "cpu": t = t.cpu() - + flat = t.flatten() total = len(flat) - + if total <= cls.MAX_TENSOR_ELEMENTS_PREVIEW: data = flat.tolist() truncated = False @@ -316,17 +326,19 @@ class ObjectParser(BaseParser): last = flat[-half:].tolist() data = first + ["..."] + last truncated = True - - return json.dumps({ - "preview": data, - "truncated": truncated, - "total_elements": total, - }) + + return json.dumps( + { + "preview": data, + "truncated": truncated, + "total_elements": total, + } + ) except Exception as e: return json.dumps({"error": str(e)}) - + # ========== PIL IMAGE METHODS ========== - + @classmethod def _get_pil_metrics(cls, img) -> Dict: """Get metrics for PIL Image.""" @@ -339,25 +351,26 @@ class ObjectParser(BaseParser): "channels": len(img.getbands()), "bands": img.getbands(), } - + # Color profile info - if hasattr(img, 'info'): - if 'icc_profile' in img.info: + if hasattr(img, "info"): + if "icc_profile" in img.info: metrics["has_icc_profile"] = True - if 'dpi' in img.info: - metrics["dpi"] = img.info['dpi'] - if 'exif' in img.info: + if "dpi" in img.info: + metrics["dpi"] = img.info["dpi"] + if "exif" in img.info: metrics["has_exif"] = True - + # Memory estimate pixels = img.width * img.height bytes_per_pixel = len(img.getbands()) metrics["memory_bytes"] = pixels * bytes_per_pixel metrics["memory_human"] = cls._format_bytes(metrics["memory_bytes"]) - + # Statistical info try: import numpy as np + arr = np.array(img) metrics["stats"] = { "min": int(arr.min()), @@ -367,49 +380,53 @@ class ObjectParser(BaseParser): } except Exception: pass - + return metrics except Exception as e: return {"error": str(e)} - + @classmethod def _get_pil_spectral(cls, img) -> Optional[Dict]: """Generate spectral/histogram data for PIL Image.""" try: import numpy as np - + arr = np.array(img) channels = arr.shape[-1] if len(arr.shape) == 3 else 1 - + spectral = { "type": "spectral", "channels": [], } - + bands = img.getbands() - + if len(arr.shape) == 2: # Grayscale hist, bin_edges = np.histogram(arr.flatten(), bins=64, range=(0, 255)) - spectral["channels"].append({ - "name": "L", - "histogram": hist.tolist(), - "bins": bin_edges.tolist(), - }) + spectral["channels"].append( + { + "name": "L", + "histogram": hist.tolist(), + "bins": bin_edges.tolist(), + } + ) else: for i in range(min(channels, 4)): channel = arr[..., i].flatten() hist, bin_edges = np.histogram(channel, bins=64, range=(0, 255)) - spectral["channels"].append({ - "name": bands[i] if i < len(bands) else f"C{i}", - "histogram": hist.tolist(), - "bins": bin_edges.tolist(), - }) - + spectral["channels"].append( + { + "name": bands[i] if i < len(bands) else f"C{i}", + "histogram": hist.tolist(), + "bins": bin_edges.tolist(), + } + ) + return spectral except Exception: return None - + @classmethod def _serialize_pil(cls, img) -> str: """Create trimmed serialization of PIL Image.""" @@ -418,19 +435,19 @@ class ObjectParser(BaseParser): "format": img.format, "mode": img.mode, "size": img.size, - "info_keys": list(img.info.keys()) if hasattr(img, 'info') else [], + "info_keys": list(img.info.keys()) if hasattr(img, "info") else [], } return json.dumps(info) except Exception as e: return json.dumps({"error": str(e)}) - + # ========== NUMPY METHODS ========== - + @classmethod def _get_numpy_metrics(cls, arr) -> Dict: """Get metrics for NumPy array.""" try: - + metrics = { "shape": list(arr.shape), "dtype": str(arr.dtype), @@ -439,9 +456,9 @@ class ObjectParser(BaseParser): "itemsize": int(arr.itemsize), "memory_bytes": int(arr.nbytes), "memory_human": cls._format_bytes(arr.nbytes), - "is_contiguous": bool(arr.flags['C_CONTIGUOUS']), + "is_contiguous": bool(arr.flags["C_CONTIGUOUS"]), } - + # Statistics try: metrics["stats"] = { @@ -452,7 +469,7 @@ class ObjectParser(BaseParser): } except Exception: pass - + # Check for image-like array if len(arr.shape) >= 2: if len(arr.shape) == 2: @@ -462,23 +479,23 @@ class ObjectParser(BaseParser): metrics["image_format"] = "HWC" metrics["resolution"] = f"{arr.shape[1]}x{arr.shape[0]}" metrics["channels"] = int(arr.shape[-1]) - + return metrics except Exception as e: return {"error": str(e)} - + @classmethod def _get_numpy_spectral(cls, arr) -> Optional[Dict]: """Generate spectral/histogram data for NumPy array.""" try: import numpy as np - + # Check if image-like is_image = False if len(arr.shape) >= 2 and len(arr.shape) <= 3: if len(arr.shape) == 2 or arr.shape[-1] in (1, 3, 4): is_image = True - + if not is_image: flat = arr.flatten() hist, bin_edges = np.histogram(flat, bins=64) @@ -487,12 +504,12 @@ class ObjectParser(BaseParser): "data": hist.tolist(), "bins": bin_edges.tolist(), } - + spectral = { "type": "spectral", "channels": [], } - + # Normalize range based on dtype if arr.dtype == np.uint8: range_val = (0, 255) @@ -500,39 +517,45 @@ class ObjectParser(BaseParser): range_val = (0.0, 1.0) else: range_val = (float(arr.min()), float(arr.max())) - + channel_names = ["R", "G", "B", "A"] - + if len(arr.shape) == 2: hist, bin_edges = np.histogram(arr.flatten(), bins=64, range=range_val) - spectral["channels"].append({ - "name": "L", - "histogram": hist.tolist(), - "bins": bin_edges.tolist(), - }) + spectral["channels"].append( + { + "name": "L", + "histogram": hist.tolist(), + "bins": bin_edges.tolist(), + } + ) else: channels = arr.shape[-1] for i in range(min(channels, 4)): channel = arr[..., i].flatten() hist, bin_edges = np.histogram(channel, bins=64, range=range_val) - spectral["channels"].append({ - "name": channel_names[i] if i < len(channel_names) else f"C{i}", - "histogram": hist.tolist(), - "bins": bin_edges.tolist(), - }) - + spectral["channels"].append( + { + "name": ( + channel_names[i] if i < len(channel_names) else f"C{i}" + ), + "histogram": hist.tolist(), + "bins": bin_edges.tolist(), + } + ) + return spectral except Exception: return None - + @classmethod def _serialize_numpy(cls, arr) -> str: """Create trimmed serialization of NumPy array.""" try: - + flat = arr.flatten() total = len(flat) - + if total <= cls.MAX_TENSOR_ELEMENTS_PREVIEW: data = flat.tolist() truncated = False @@ -542,17 +565,19 @@ class ObjectParser(BaseParser): last = flat[-half:].tolist() data = first + ["..."] + last truncated = True - - return json.dumps({ - "preview": data, - "truncated": truncated, - "total_elements": total, - }) + + return json.dumps( + { + "preview": data, + "truncated": truncated, + "total_elements": total, + } + ) except Exception as e: return json.dumps({"error": str(e)}) - + # ========== SAFETENSORS METHODS ========== - + @classmethod def _get_safetensors_metrics(cls, obj) -> Dict: """Get metrics for SafeTensors object/dict.""" @@ -563,78 +588,92 @@ class ObjectParser(BaseParser): "total_bytes": 0, "tensor_info": [], } - + # Handle different safetensors representations - if hasattr(obj, 'keys'): + if hasattr(obj, "keys"): keys = list(obj.keys()) metrics["tensor_count"] = len(keys) - + for key in keys[:50]: # Limit to first 50 for display try: - if hasattr(obj, 'get_tensor'): + if hasattr(obj, "get_tensor"): tensor = obj.get_tensor(key) else: tensor = obj[key] - - if hasattr(tensor, 'shape'): + + if hasattr(tensor, "shape"): shape = list(tensor.shape) numel = 1 for s in shape: numel *= s metrics["total_parameters"] += numel - - dtype = str(tensor.dtype) if hasattr(tensor, 'dtype') else "unknown" - metrics["tensor_info"].append({ - "name": key, - "shape": shape, - "dtype": dtype, - "params": numel, - }) + + dtype = ( + str(tensor.dtype) + if hasattr(tensor, "dtype") + else "unknown" + ) + metrics["tensor_info"].append( + { + "name": key, + "shape": shape, + "dtype": dtype, + "params": numel, + } + ) except Exception: - metrics["tensor_info"].append({ - "name": key, - "error": "Could not read tensor", - }) - + metrics["tensor_info"].append( + { + "name": key, + "error": "Could not read tensor", + } + ) + if len(keys) > 50: - metrics["tensor_info"].append({ - "name": f"... and {len(keys) - 50} more tensors", - "truncated": True, - }) - - metrics["total_params_human"] = cls._format_number(metrics["total_parameters"]) - + metrics["tensor_info"].append( + { + "name": f"... and {len(keys) - 50} more tensors", + "truncated": True, + } + ) + + metrics["total_params_human"] = cls._format_number( + metrics["total_parameters"] + ) + return metrics except Exception as e: return {"error": str(e)} - + @classmethod def _serialize_safetensors(cls, obj) -> str: """Create trimmed serialization of SafeTensors.""" try: - if hasattr(obj, 'keys'): + if hasattr(obj, "keys"): keys = list(obj.keys()) - return json.dumps({ - "tensor_names": keys[:100], - "truncated": len(keys) > 100, - "total_tensors": len(keys), - }) + return json.dumps( + { + "tensor_names": keys[:100], + "truncated": len(keys) > 100, + "total_tensors": len(keys), + } + ) return json.dumps({"type": "safetensors", "readable": False}) except Exception as e: return json.dumps({"error": str(e)}) - + # ========== DICT/LIST METHODS ========== - + @classmethod def _get_dict_metrics(cls, obj: dict) -> Dict: """Get metrics for dictionary.""" return { "key_count": len(obj), - "keys": list(obj.keys())[:cls.MAX_DICT_KEYS], + "keys": list(obj.keys())[: cls.MAX_DICT_KEYS], "truncated_keys": len(obj) > cls.MAX_DICT_KEYS, "memory_estimate": cls._estimate_size(obj), } - + @classmethod def _serialize_dict(cls, obj: dict) -> str: """Create trimmed serialization of dict.""" @@ -642,13 +681,15 @@ class ObjectParser(BaseParser): trimmed = {} for i, (k, v) in enumerate(obj.items()): if i >= cls.MAX_DICT_KEYS: - trimmed["__truncated__"] = f"{len(obj) - cls.MAX_DICT_KEYS} more keys..." + trimmed["__truncated__"] = ( + f"{len(obj) - cls.MAX_DICT_KEYS} more keys..." + ) break trimmed[str(k)] = cls._serialize_value(v) return json.dumps(trimmed, default=str, indent=2) except Exception as e: return json.dumps({"error": str(e)}) - + @classmethod def _get_list_metrics(cls, obj) -> Dict: """Get metrics for list/tuple.""" @@ -658,7 +699,7 @@ class ObjectParser(BaseParser): "item_types": list(set(type(x).__name__ for x in obj[:100])), "memory_estimate": cls._estimate_size(obj), } - + @classmethod def _serialize_list(cls, obj) -> str: """Create trimmed serialization of list.""" @@ -673,38 +714,40 @@ class ObjectParser(BaseParser): return json.dumps(items, default=str, indent=2) except Exception as e: return json.dumps({"error": str(e)}) - + # ========== GENERIC OBJECT METHODS ========== - + @classmethod def _get_object_attributes(cls, obj) -> Dict: """Get attributes of a generic object.""" attrs = {} - + try: # Get all attributes for name in dir(obj): - if name.startswith('__'): + if name.startswith("__"): continue try: value = getattr(obj, name) if callable(value): continue # Skip methods - + attrs[name] = { "type": type(value).__name__, - "value": cls._serialize_value(value, max_len=cls.MAX_ATTR_VALUE_LENGTH), + "value": cls._serialize_value( + value, max_len=cls.MAX_ATTR_VALUE_LENGTH + ), } - + if len(attrs) >= cls.MAX_DICT_KEYS: break except Exception: pass except Exception: pass - + return attrs - + @classmethod def _get_object_metrics(cls, obj) -> Dict: """Get metrics for a generic object.""" @@ -713,69 +756,79 @@ class ObjectParser(BaseParser): "module": type(obj).__module__, "id": id(obj), } - + # Try to get size try: metrics["size_bytes"] = sys.getsizeof(obj) metrics["size_human"] = cls._format_bytes(metrics["size_bytes"]) except Exception: pass - + # Count attributes try: - attrs = [a for a in dir(obj) if not a.startswith('__') and not callable(getattr(obj, a, None))] - methods = [m for m in dir(obj) if not m.startswith('__') and callable(getattr(obj, m, None))] + attrs = [ + a + for a in dir(obj) + if not a.startswith("__") and not callable(getattr(obj, a, None)) + ] + methods = [ + m + for m in dir(obj) + if not m.startswith("__") and callable(getattr(obj, m, None)) + ] metrics["attribute_count"] = len(attrs) metrics["method_count"] = len(methods) except Exception: pass - + # Check for common interfaces metrics["interfaces"] = [] - if hasattr(obj, '__iter__'): + if hasattr(obj, "__iter__"): metrics["interfaces"].append("iterable") - if hasattr(obj, '__len__'): + if hasattr(obj, "__len__"): metrics["interfaces"].append("sized") try: metrics["length"] = len(obj) except Exception: pass - if hasattr(obj, '__call__'): + if hasattr(obj, "__call__"): metrics["interfaces"].append("callable") - if hasattr(obj, '__getitem__'): + if hasattr(obj, "__getitem__"): metrics["interfaces"].append("subscriptable") - + return metrics - + @classmethod def _get_source_info(cls, obj) -> Optional[Dict]: """Try to get source file info for the object's class.""" try: cls_type = type(obj) source_info = {} - + try: source_info["file"] = inspect.getfile(cls_type) except Exception: pass - + try: source_info["source_lines"] = len(inspect.getsourcelines(cls_type)[0]) except Exception: pass - + # Get class hierarchy try: - mro = [c.__name__ for c in cls_type.__mro__[1:-1]] # Skip self and object + mro = [ + c.__name__ for c in cls_type.__mro__[1:-1] + ] # Skip self and object if mro: source_info["bases"] = mro except Exception: pass - + return source_info if source_info else None except Exception: return None - + @classmethod def _serialize_object(cls, obj) -> str: """Create trimmed serialization of generic object.""" @@ -783,22 +836,24 @@ class ObjectParser(BaseParser): # Try repr first repr_str = repr(obj) if len(repr_str) > cls.MAX_STRING_LENGTH: - repr_str = repr_str[:cls.MAX_STRING_LENGTH] + "..." - - return json.dumps({ - "repr": repr_str, - "type": type(obj).__name__, - }) + repr_str = repr_str[: cls.MAX_STRING_LENGTH] + "..." + + return json.dumps( + { + "repr": repr_str, + "type": type(obj).__name__, + } + ) except Exception as e: return json.dumps({"error": str(e)}) - + # ========== UTILITY METHODS ========== - + @classmethod def _serialize_value(cls, value, max_len: int = None) -> str: """Serialize a single value with truncation.""" max_len = max_len or cls.MAX_ATTR_VALUE_LENGTH - + try: if value is None: return "null" @@ -816,16 +871,16 @@ class ObjectParser(BaseParser): return str(value)[:max_len] if isinstance(value, dict): return f"{{dict of {len(value)} keys}}" - if hasattr(value, 'shape'): + if hasattr(value, "shape"): return f"<{type(value).__name__} shape={list(value.shape)}>" - + s = repr(value) if len(s) > max_len: return s[:max_len] + "..." return s except Exception: return "" - + @classmethod def _estimate_size(cls, obj) -> str: """Estimate memory size of object.""" @@ -834,34 +889,34 @@ class ObjectParser(BaseParser): return cls._format_bytes(size) except Exception: return "unknown" - + @staticmethod def _format_bytes(size: int) -> str: """Format bytes to human readable.""" - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + for unit in ["B", "KB", "MB", "GB", "TB"]: if abs(size) < 1024: return f"{size:.1f} {unit}" size /= 1024 return f"{size:.1f} PB" - + @staticmethod def _format_number(num: int) -> str: """Format large number to human readable.""" if num < 1000: return str(num) - for unit in ['', 'K', 'M', 'B', 'T']: + for unit in ["", "K", "M", "B", "T"]: if abs(num) < 1000: return f"{num:.1f}{unit}" num /= 1000 return f"{num:.1f}P" - + @classmethod def detect_output(cls, content: str) -> bool: """Check if content has object output marker.""" if not isinstance(content, str): return False return content.startswith(cls.OUTPUT_MARKER) - + @classmethod def parse_output(cls, content: str, logger=None) -> dict: """Parse object output - typically just pass through.""" diff --git a/modules/parsers/python_parser.py b/modules/parsers/python_parser.py index 7affa54..9f2dc18 100644 --- a/modules/parsers/python_parser.py +++ b/modules/parsers/python_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class PythonParser(BaseParser): """Python parser for Python code display.""" - + PARSER_NAME = "python" PARSER_PRIORITY = 10 diff --git a/modules/parsers/svg_parser.py b/modules/parsers/svg_parser.py index 628fa50..ba8cb18 100644 --- a/modules/parsers/svg_parser.py +++ b/modules/parsers/svg_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class SvgParser(BaseParser): """SVG parser for SVG content display.""" - + PARSER_NAME = "svg" PARSER_PRIORITY = 10 diff --git a/modules/parsers/text_parser.py b/modules/parsers/text_parser.py index 792fa2d..b321e0f 100644 --- a/modules/parsers/text_parser.py +++ b/modules/parsers/text_parser.py @@ -1,7 +1,7 @@ """ Text Parser for WAS Content Viewer. -Handles plain text content. +Handles plain text content. """ @@ -10,6 +10,6 @@ from .base_parser import BaseParser class TextParser(BaseParser): """Text parser - fallback for plain text content.""" - + PARSER_NAME = "text" PARSER_PRIORITY = 1 # Low priority - fallback parser diff --git a/modules/parsers/yaml_parser.py b/modules/parsers/yaml_parser.py index 344d847..078dfcb 100644 --- a/modules/parsers/yaml_parser.py +++ b/modules/parsers/yaml_parser.py @@ -8,6 +8,6 @@ from .base_parser import BaseParser class YamlParser(BaseParser): """YAML parser for YAML content display.""" - + PARSER_NAME = "yaml" PARSER_PRIORITY = 10 diff --git a/prestartup_script.py b/prestartup_script.py index c1738f1..c973e1b 100644 --- a/prestartup_script.py +++ b/prestartup_script.py @@ -15,7 +15,6 @@ The script will: - Create a log file to track installation (skip if already installed) """ -import os import sys import zipfile import shutil @@ -24,7 +23,6 @@ import datetime import json from pathlib import Path - # Get paths SCRIPT_DIR = Path(__file__).parent.resolve() EXTENSIONS_DIR = SCRIPT_DIR / "extensions" @@ -36,6 +34,8 @@ EXTRACT_FOLDERS = { "nodes": SCRIPT_DIR / "nodes", "web/views": SCRIPT_DIR / "web" / "views", "modules/parsers": SCRIPT_DIR / "modules" / "parsers", + "routes": SCRIPT_DIR / "routes", + "apps": SCRIPT_DIR / "apps", } @@ -61,7 +61,7 @@ def ensure_directories(): """Ensure extensions and logs directories exist.""" EXTENSIONS_DIR.mkdir(exist_ok=True) LOGS_DIR.mkdir(exist_ok=True) - + # Create .gitkeep to preserve empty folders gitkeep = EXTENSIONS_DIR / ".gitkeep" if not gitkeep.exists(): @@ -91,7 +91,7 @@ def find_extension_root(zip_ref: zipfile.ZipFile) -> str: parts = name.split("/") if len(parts) > 1: roots.add(parts[0]) - + if len(roots) == 1: return list(roots)[0] return "" @@ -106,46 +106,51 @@ def extract_extension(zip_path: Path, log_lines: list) -> bool: with zipfile.ZipFile(zip_path, "r") as zip_ref: root = find_extension_root(zip_ref) if not root: - log_lines.append("ERROR: Could not determine extension root folder in zip") + log_lines.append( + "ERROR: Could not determine extension root folder in zip" + ) return False - + log_lines.append(f"Extension root: {root}") extracted_count = 0 - + for source_folder, dest_folder in EXTRACT_FOLDERS.items(): source_prefix = f"{root}/{source_folder}/" - + # Find matching files matching_files = [ - name for name in zip_ref.namelist() + name + for name in zip_ref.namelist() if name.startswith(source_prefix) and not name.endswith("/") ] - + if not matching_files: log_lines.append(f" No files found in {source_folder}/") continue - - log_lines.append(f" Extracting {len(matching_files)} files from {source_folder}/") - + + log_lines.append( + f" Extracting {len(matching_files)} files from {source_folder}/" + ) + for file_path in matching_files: # Calculate relative path and destination - rel_path = file_path[len(source_prefix):] + rel_path = file_path[len(source_prefix) :] dest_path = dest_folder / rel_path - + # Ensure parent directory exists dest_path.parent.mkdir(parents=True, exist_ok=True) - + # Extract file with zip_ref.open(file_path) as src: with open(dest_path, "wb") as dst: dst.write(src.read()) - + extracted_count += 1 log_lines.append(f" -> {dest_path.relative_to(SCRIPT_DIR)}") - + log_lines.append(f"Extracted {extracted_count} files total") return extracted_count > 0 - + except zipfile.BadZipFile: log_lines.append("ERROR: Invalid or corrupted zip file") return False @@ -163,45 +168,47 @@ def extract_and_install_requirements(zip_path: Path, log_lines: list) -> bool: with zipfile.ZipFile(zip_path, "r") as zip_ref: root = find_extension_root(zip_ref) requirements_path = f"{root}/requirements.txt" - + # Check if requirements.txt exists in zip if requirements_path not in zip_ref.namelist(): log_lines.append("No requirements.txt found - skipping pip install") return True - + # Extract to extensions folder with unique name base_name = zip_path.stem dest_requirements = EXTENSIONS_DIR / f"{base_name}_requirements.txt" - + with zip_ref.open(requirements_path) as src: content = src.read().decode("utf-8") dest_requirements.write_text(content) - + log_lines.append(f"Extracted requirements to: {dest_requirements.name}") log_lines.append(f"Requirements content:\n{content}") - + # Install using pip python_exe = get_python_executable() log_lines.append(f"Installing requirements with: {python_exe}") - + result = subprocess.run( [python_exe, "-m", "pip", "install", "-r", str(dest_requirements)], capture_output=True, text=True, - timeout=300 # 5 minute timeout + timeout=300, # 5 minute timeout ) - + log_lines.append(f"pip stdout:\n{result.stdout}") if result.stderr: log_lines.append(f"pip stderr:\n{result.stderr}") - + if result.returncode != 0: - log_lines.append(f"ERROR: pip install failed with code {result.returncode}") + log_lines.append( + f"ERROR: pip install failed with code {result.returncode}" + ) return False - + log_lines.append("Requirements installed successfully") return True - + except subprocess.TimeoutExpired: log_lines.append("ERROR: pip install timed out after 5 minutes") return False @@ -217,83 +224,172 @@ def install_extension(zip_path: Path) -> bool: """ zip_name = zip_path.name # Extract friendly name (remove -main/-master suffix and .zip) - friendly_name = zip_name.replace("-main.zip", "").replace("-master.zip", "").replace(".zip", "") - + friendly_name = ( + zip_name.replace("-main.zip", "").replace("-master.zip", "").replace(".zip", "") + ) + log_lines = [] - log_lines.append(f"=" * 60) - log_lines.append(f"ComfyUI_Viewer Extension Installation Log") + log_lines.append("=" * 60) + log_lines.append("ComfyUI_Viewer Extension Installation Log") log_lines.append(f"Extension: {zip_name}") log_lines.append(f"Date: {datetime.datetime.now().isoformat()}") - log_lines.append(f"=" * 60) + log_lines.append("=" * 60) log_lines.append("") - + print_progress(f"Installing: {friendly_name}", "HEADER") - + # Step 1: Extract folders print_progress("Extracting files...", "PROGRESS") log_lines.append("Step 1: Extracting files") log_lines.append("-" * 40) - + if not extract_extension(zip_path, log_lines): log_lines.append("\nINSTALLATION FAILED: Extraction error") - print_progress(f"Extraction failed", "ERROR") + print_progress("Extraction failed", "ERROR") return False - + log_lines.append("") - + # Step 2: Install requirements print_progress("Installing dependencies...", "PROGRESS") log_lines.append("Step 2: Installing requirements") log_lines.append("-" * 40) - + if not extract_and_install_requirements(zip_path, log_lines): log_lines.append("\nINSTALLATION FAILED: Requirements error") - print_progress(f"Dependency installation failed", "ERROR") + print_progress("Dependency installation failed", "ERROR") return False - + log_lines.append("") log_lines.append("=" * 60) log_lines.append("INSTALLATION COMPLETED SUCCESSFULLY") log_lines.append("=" * 60) - + # Write log file (marks as installed) log_path = get_log_path(zip_name) log_path.write_text("\n".join(log_lines)) - - print_progress(f"Installed successfully", "OK") - + + print_progress("Installed successfully", "OK") + return True +def sync_extension_directories(): + """ + Sync sibling ComfyUI_Viewer_* extension directories into ComfyUI_Viewer. + + Extensions can be installed either from .zip files (handled by run_extension_installer) + or placed as sibling directories alongside ComfyUI_Viewer. This function handles the + latter by copying all extension assets into ComfyUI_Viewer so the node loader, parser + loader, and view loader can discover them. + + Runs every startup to keep files in sync. Uses the same folder mapping as + EXTRACT_FOLDERS to stay consistent with zip-based installation. + """ + workspace_dir = SCRIPT_DIR.parent + if not workspace_dir.is_dir(): + return + + for entry in sorted(workspace_dir.iterdir()): + if not entry.name.startswith("ComfyUI_Viewer_") or not entry.is_dir(): + continue + + for source_folder, dest_folder in EXTRACT_FOLDERS.items(): + ext_source = entry / source_folder + if not ext_source.is_dir(): + continue + + dest_folder.mkdir(parents=True, exist_ok=True) + + # Walk the source tree and copy all files + for src_file in ext_source.rglob("*"): + if src_file.is_dir() or src_file.name.startswith("_"): + continue + rel = src_file.relative_to(ext_source) + dest_path = dest_folder / rel + try: + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src_file, dest_path) + except Exception as e: + print_progress( + f"Failed to copy {rel} from {entry.name}: {e}", "WARN" + ) + + def update_extension_views_json(): """ Update extension_views.json with list of installed extension view files. This allows the view_loader to dynamically discover extension views. + + Scans two locations: + 1. Installed views in ComfyUI_Viewer/web/views/ (from zip installs) + 2. Sibling ComfyUI_Viewer_* directories for dev/unzipped extensions """ views_dir = SCRIPT_DIR / "web" / "views" - + # Core views from view_manifest.js (these are not extension views) core_views = { - "canvas.js", "html.js", "svg.js", "markdown.js", "json.js", - "csv.js", "yaml.js", "ansi.js", "python.js", "javascript.js", - "css.js", "object.js", "text.js" + "canvas.js", + "html.js", + "svg.js", + "markdown.js", + "json.js", + "csv.js", + "yaml.js", + "ansi.js", + "python.js", + "javascript.js", + "css.js", + "object.js", + "text.js", } - + + skip_files = { + "view_manifest.js", + "view_loader.js", + "base_view.js", + "code_scripts.js", + } + # Find all .js files in views folder that aren't core views or utility files extension_views = [] + seen = set() if views_dir.exists(): for js_file in views_dir.glob("*.js"): filename = js_file.name - # Skip core views, manifest, loader, and base class - if filename in core_views: - continue - if filename in ("view_manifest.js", "view_loader.js", "base_view.js", "code_scripts.js"): + if filename in core_views or filename in skip_files: continue extension_views.append(filename) - + seen.add(filename) + + # Scan sibling ComfyUI_Viewer_* extension directories for additional views + workspace_dir = SCRIPT_DIR.parent + if workspace_dir.is_dir(): + for entry in sorted(workspace_dir.iterdir()): + if not entry.name.startswith("ComfyUI_Viewer_") or not entry.is_dir(): + continue + ext_views_dir = entry / "web" / "views" + if not ext_views_dir.is_dir(): + continue + for js_file in ext_views_dir.glob("*.js"): + filename = js_file.name + if filename in core_views or filename in skip_files or filename in seen: + continue + # Copy the view file into our views directory so the frontend can load it + dest = views_dir / filename + try: + shutil.copy2(js_file, dest) + extension_views.append(filename) + seen.add(filename) + except Exception as e: + print_progress( + f"Failed to copy extension view {filename} from {entry.name}: {e}", + "WARN", + ) + # Write to JSON file try: - EXTENSION_VIEWS_JSON.write_text(json.dumps(extension_views, indent=2)) + EXTENSION_VIEWS_JSON.write_text(json.dumps(sorted(extension_views), indent=2)) except Exception as e: print_progress(f"Failed to update extension_views.json: {e}", "WARN") @@ -301,43 +397,45 @@ def update_extension_views_json(): def run_extension_installer(): """Main entry point - scan and install extensions.""" ensure_directories() - + # Find all .zip files in extensions folder zip_files = list(EXTENSIONS_DIR.glob("*.zip")) - + if not zip_files: return # No extensions to install, silent exit - + # Filter to only uninstalled extensions to_install = [z for z in zip_files if not is_installed(z.name)] - + if not to_install: return # All installed, silent exit - + print("") print("\033[95m ╔══════════════════════════════════════════════════╗\033[0m") - print("\033[95m ║\033[0m \033[1mComfyUI_Viewer Extension Installer\033[0m \033[95m║\033[0m") + print( + "\033[95m ║\033[0m \033[1mComfyUI_Viewer Extension Installer\033[0m \033[95m║\033[0m" + ) print("\033[95m ╚══════════════════════════════════════════════════╝\033[0m") print("") print_progress(f"Found {len(to_install)} new extension(s) to install", "INFO") print("") - + success_count = 0 fail_count = 0 - + for i, zip_path in enumerate(to_install, 1): if install_extension(zip_path): success_count += 1 else: fail_count += 1 - + print("") if fail_count == 0: print_progress(f"Done! {success_count} extension(s) installed", "OK") else: print_progress(f"Installed: {success_count}, Failed: {fail_count}", "WARN") print("") - + # Update extension views manifest for view_loader auto-discovery if success_count > 0: update_extension_views_json() @@ -345,3 +443,9 @@ def run_extension_installer(): # Run when imported by ComfyUI run_extension_installer() + +# Sync sibling extension directories (nodes, parsers, web assets) into ComfyUI_Viewer +sync_extension_directories() + +# Update extension views manifest for view_loader auto-discovery +update_extension_views_json() diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..814d869 --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,73 @@ +""" +ComfyUI_Viewer Route Loader + +Automatically discovers and registers API routes from extension route files. +Extension routes should be placed in the routes/ directory. + +Route files should define route handlers using the PromptServer.instance.routes decorator: + +Example route file (routes/my_extension_routes.py): + from aiohttp import web + from server import PromptServer + + @PromptServer.instance.routes.get('/was/my_extension/api/data') + async def get_data(request): + return web.json_response({'status': 'ok'}) + +Routes are automatically registered when ComfyUI starts. +""" + +import os +import importlib.util +import logging + +logger = logging.getLogger("WAS.ContentViewer.RouteLoader") + + +def load_routes(): + """ + Discover and load all route files from the routes/ directory. + + Route files are loaded directly without requiring package structure. + Any .py file in routes/ (except those starting with _) will be loaded. + """ + routes_dir = os.path.dirname(__file__) + + if not os.path.isdir(routes_dir): + return + + loaded_count = 0 + failed_count = 0 + loaded_files = [] + failed_files = [] + + for filename in sorted(os.listdir(routes_dir)): + if not filename.endswith('.py') or filename.startswith('_'): + continue + + filepath = os.path.join(routes_dir, filename) + module_name = f"ComfyUI_Viewer.routes.{filename[:-3]}" + + try: + spec = importlib.util.spec_from_file_location(module_name, filepath) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + import sys + sys.modules[module_name] = module + spec.loader.exec_module(module) + loaded_count += 1 + loaded_files.append(filename) + except Exception as e: + failed_count += 1 + failed_files.append(f"{filename}: {e}") + logger.error(f"[WAS Viewer Routes] Failed to load {filename}: {e}") + + if loaded_count > 0: + logger.info(f"[WAS Viewer Routes] Loaded {loaded_count} route file(s): {', '.join(loaded_files)}") + + if failed_count > 0: + logger.error(f"[WAS Viewer Routes] Failed to load {failed_count} route file(s): {', '.join(failed_files)}") + + +# Load routes when this module is imported +load_routes() diff --git a/web/comfy_viewer.js b/web/comfy_viewer.js index ccbff31..9f03bb4 100644 --- a/web/comfy_viewer.js +++ b/web/comfy_viewer.js @@ -13,7 +13,11 @@ import { isMultiviewContent, parseMultiviewContent, getMultiviewContent, - isViewUI + isViewUI, + getViewSandboxAttributes, + viewNeedsBlobUrl, + getViewDirectUrl, + getViewContentMessage } from "./views/view_loader.js"; import { loadPrismScripts } from "./views/code_scripts.js"; import { computeThemeTokens, getFullTheme, themeToCssVars } from "./utils/theme.js"; @@ -23,6 +27,15 @@ import { buildIframeContent, LIST_SEPARATOR } from "./iframe/iframe_builder.js"; const EXT_NAME = "WAS.ContentViewer"; const NODE_NAME = "WASComfyViewer"; +// Diagnostic v2: fresh key to avoid stale data confusion +const _diag2 = []; +function diag(msg) { + const ts = performance.now().toFixed(0); + _diag2.push(`[${ts}] ${msg}`); + try { localStorage.setItem('__was_diag2', JSON.stringify(_diag2.slice(-200))); } catch(e) {} +} + + const DEFAULT_NODE_SIZE = [600, 500]; const STATE = { @@ -333,6 +346,7 @@ function removeElementsByKey(key) { try { const elements = STATE.nodeIdToElements.get(key); if (!elements) return; + diag(`removeElementsByKey: key=${key} directUrlLoaded=${elements?.directUrlLoaded}`); elements.wrapper?.remove(); } catch (e) { console.error("[WAS Viewer] removeElementsByKey error:", e); @@ -344,11 +358,15 @@ function removeElementsByKey(key) { function cleanupOrphanElements() { try { const nodes = getActiveGraphNodes(); - const activeIds = new Set( - nodes - .filter((n) => isViewerNode(n)) - .map((n) => String(n.id)) - ); + + // During hard refresh the graph may not be populated yet. If we have + // tracked elements but the graph reports zero viewer nodes, skip this + // cycle — otherwise we'd destroy iframes that will be needed as soon + // as the graph finishes loading, causing a create-destroy-create loop. + const viewerNodes = nodes.filter((n) => isViewerNode(n)); + if (viewerNodes.length === 0 && STATE.nodeIdToElements.size > 0) return; + + const activeIds = new Set(viewerNodes.map((n) => String(n.id))); for (const key of Array.from(STATE.nodeIdToElements.keys())) { if (!activeIds.has(key)) { @@ -543,6 +561,20 @@ function getNodeContent(node, elements) { return ""; } +/** + * Check if the upstream node connected to "content" input is a specific + * ComfyUI class type (e.g. an OpenReel node). Returns the comfyClass string + * of the upstream node, or null if nothing is connected. + */ +function getUpstreamNodeClass(node) { + const inputLink = node.inputs?.find((i) => i.name === "content")?.link; + if (inputLink == null) return null; + const link = app.graph.links?.[inputLink]; + if (!link) return null; + const originNode = app.graph.getNodeById(link.origin_id); + return originNode?.comfyClass || originNode?.type || null; +} + function ensureElementsForNode(node) { ensureCleanupRunning(); @@ -606,7 +638,7 @@ function ensureElementsForNode(node) { `; const iframe = document.createElement("iframe"); - iframe.setAttribute("sandbox", "allow-scripts allow-forms allow-popups allow-modals allow-pointer-lock allow-downloads"); + iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-modals allow-pointer-lock allow-downloads"); iframe.setAttribute("allow", "fullscreen"); iframe.setAttribute("allowfullscreen", "true"); iframe.style.cssText = ` @@ -793,7 +825,7 @@ function processIframeQueue() { if (STATE.iframeLoading || STATE.iframeLoadQueue.length === 0) return; STATE.iframeLoading = true; - const { elements, html, needsBlobUrl, scriptData } = STATE.iframeLoadQueue.shift(); + const { elements, html, needsBlobUrl, scriptData, directUrl } = STATE.iframeLoadQueue.shift(); if (!elements?.iframe) { STATE.iframeLoading = false; @@ -804,7 +836,7 @@ function processIframeQueue() { const onLoad = () => { elements.iframe.removeEventListener("load", onLoad); - if (scriptData && scriptData.length > 0) { + if (!directUrl && scriptData && scriptData.length > 0) { try { elements.iframe.contentWindow.postMessage({ type: 'was-inject-scripts', @@ -828,18 +860,33 @@ function processIframeQueue() { } }, 3000); - if (needsBlobUrl) { - if (elements.lastBlobUrl) { - URL.revokeObjectURL(elements.lastBlobUrl); + // Clean up any previous blob URL + if (elements.lastBlobUrl) { + URL.revokeObjectURL(elements.lastBlobUrl); + elements.lastBlobUrl = null; + } + + if (directUrl) { + // View provides its own URL — load directly via src (no srcdoc/blob wrapper). + if (elements.pendingSandbox && elements.iframe.getAttribute("sandbox") !== elements.pendingSandbox) { + elements.iframe.setAttribute("sandbox", elements.pendingSandbox); } + elements.iframe.removeAttribute("srcdoc"); + diag(`processIframeQueue: setting iframe.src to directUrl`); + elements.iframe.src = directUrl; + } else if (needsBlobUrl) { + if (elements.pendingSandbox && elements.iframe.getAttribute("sandbox") !== elements.pendingSandbox) { + elements.iframe.setAttribute("sandbox", elements.pendingSandbox); + } + elements.lastDirectUrlKey = null; const blob = new Blob([html], { type: "text/html" }); elements.lastBlobUrl = URL.createObjectURL(blob); elements.iframe.src = elements.lastBlobUrl; } else { - if (elements.lastBlobUrl) { - URL.revokeObjectURL(elements.lastBlobUrl); - elements.lastBlobUrl = null; + if (elements.pendingSandbox && elements.iframe.getAttribute("sandbox") !== elements.pendingSandbox) { + elements.iframe.setAttribute("sandbox", elements.pendingSandbox); } + elements.lastDirectUrlKey = null; elements.iframe.src = ""; elements.iframe.srcdoc = html; } @@ -850,8 +897,21 @@ function updateIframeContent(node, elements, forceView = null) { const content = getNodeContent(node, elements); const contentHash = content ? content.length + "_" + content.slice(0, 100) : ""; - if (contentHash === elements.lastContentHash && !forceView) return; - elements.lastContentHash = contentHash; + + // When content is empty but the upstream node is a UI view provider (e.g. + // OpenReel), load the view in standalone mode so the user can work in it + // before the workflow has been executed. We use a special hash so this + // only runs once (not every frame). + let upstreamViewType = null; + if (!content) { + const upstreamClass = getUpstreamNodeClass(node); + if (upstreamClass && upstreamClass.includes("OpenReel")) { + upstreamViewType = "openreel_video"; + } + } + const effectiveHash = upstreamViewType ? ("_upstream:" + upstreamViewType) : contentHash; + if (effectiveHash === elements.lastContentHash && !forceView) return; + elements.lastContentHash = effectiveHash; const theme = computeThemeTokens(); let displayContent = content; @@ -878,7 +938,7 @@ function updateIframeContent(node, elements, forceView = null) { contentType = detectContentType(content); } } else { - contentType = detectContentType(content); + contentType = upstreamViewType || detectContentType(content); elements.currentView = null; elements.multiviewContent = null; if (elements.viewSelector) { @@ -890,6 +950,12 @@ function updateIframeContent(node, elements, forceView = null) { elements.typeLabel.textContent = `Type: ${getViewDisplayName(contentType)}`; } + // Store desired sandbox attrs — applied in processIframeQueue right before + // loading new content, so we never change sandbox on an already-loaded iframe + // (which would trigger an unwanted browser reload). + elements.pendingSandbox = getViewSandboxAttributes(contentType); + + elements.contentType = contentType; updateControlsForUI(elements, isViewUI(contentType)); const LIST_SEPARATOR = "\n---LIST_SEPARATOR---\n"; @@ -924,18 +990,59 @@ function updateIframeContent(node, elements, forceView = null) { html = buildIframeContent(finalContent, contentType, theme, excluded, nodeId); } - const needsBlobUrl = displayContent && contentType === "html" && ( - displayContent.includes("WebAssembly") || - displayContent.includes("wasm") || - displayContent.includes("createUnityInstance") || - displayContent.includes("ServiceWorker") || - displayContent.includes("SharedArrayBuffer") + // Check if the view provides a direct URL (e.g. OpenReel app served by its own endpoint). + // Pass content even if empty — views like OpenReel can load standalone for manual use. + const directUrl = getViewDirectUrl(contentType, finalContent || '', theme); + + // If the view provides a directUrl AND we already loaded it, send content + // updates via postMessage instead of reloading the iframe. + diag(`updateIframeContent: directUrl=${!!directUrl} directUrlLoaded=${!!elements.directUrlLoaded} nodeId=${String(node.id)} hasContent=${!!displayContent}`); + + // For OpenReel: defer loading until we have actual content to avoid hard refresh race conditions + if (directUrl && !displayContent && !elements.directUrlLoaded) { + diag(`updateIframeContent: OpenReel deferred - no content yet`); + // Show placeholder, don't load the app yet + const placeholderHtml = buildIframeContent("

Waiting for video content...

", "html", theme, [], nodeId); + elements.iframe.srcdoc = placeholderHtml; + elements.lastContentHash = effectiveHash; + return; + } + + if (directUrl && elements.directUrlLoaded) { + diag(`updateIframeContent: DEDUP`); + if (displayContent && elements.iframe?.contentWindow) { + const contentMsg = getViewContentMessage(contentType, displayContent); + if (contentMsg) { + try { + elements.iframe.contentWindow.postMessage(contentMsg, '*'); + } catch (e) {} + } + } + elements.lastContentHash = effectiveHash; + return; + } + + const needsBlobUrl = !directUrl && displayContent && ( + viewNeedsBlobUrl(contentType) || + (contentType === "html" && ( + displayContent.includes("WebAssembly") || + displayContent.includes("wasm") || + displayContent.includes("createUnityInstance") || + displayContent.includes("ServiceWorker") || + displayContent.includes("SharedArrayBuffer") + )) ); const scriptData = displayContent ? getViewScriptData(contentType, finalContent) : []; - + + // Mark that a directUrl load has been queued. + if (directUrl) { + diag(`updateIframeContent: queuing directUrl load`); + elements.directUrlLoaded = true; + } + STATE.iframeLoadQueue = STATE.iframeLoadQueue.filter(item => item.elements !== elements); - STATE.iframeLoadQueue.push({ elements, html, needsBlobUrl, scriptData }); + STATE.iframeLoadQueue.push({ elements, html, needsBlobUrl, scriptData, directUrl }); processIframeQueue(); } @@ -977,6 +1084,27 @@ window.addEventListener("message", (event) => { node.setDirtyCanvas?.(true, true); } } + } else if (event.data?.type === "openreel-ready") { + // The OpenReel app inside the iframe is ready to receive content. + // Re-send the current content so video gets imported even if the + // earlier postMessage arrived before the React listener was active. + for (const [nId, elements] of STATE.nodeIdToElements.entries()) { + if (elements.iframe && elements.iframe.contentWindow === event.source) { + const node = app.graph?.getNodeById(parseInt(nId)); + if (node && elements.contentType) { + const content = getNodeContent(node, elements); + if (content) { + const contentMsg = getViewContentMessage(elements.contentType, content); + if (contentMsg) { + try { + elements.iframe.contentWindow.postMessage(contentMsg, '*'); + } catch (e) {} + } + } + } + break; + } + } } else { const messageType = event.data?.type; const nodeId = event.data?.nodeId; diff --git a/web/views/view_loader.js b/web/views/view_loader.js index bcdbe7f..83e4581 100644 --- a/web/views/view_loader.js +++ b/web/views/view_loader.js @@ -252,6 +252,74 @@ export function isViewUI(contentType) { return view?.isUI === true; } +/** + * Default sandbox attributes for the Content Viewer iframe. + */ +const DEFAULT_SANDBOX = "allow-scripts allow-forms allow-popups allow-modals allow-pointer-lock allow-downloads"; + +/** + * Get sandbox attributes for the Content Viewer iframe based on the current view. + * Views can declare additional sandbox permissions via a static getSandboxAttributes() method. + * @param {string} contentType + * @returns {string} sandbox attribute value + */ +export function getViewSandboxAttributes(contentType) { + const view = VIEW_REGISTRY.get(contentType); + if (view && typeof view.getSandboxAttributes === 'function') { + return view.getSandboxAttributes(); + } + return DEFAULT_SANDBOX; +} + +/** + * Check if a view requires a blob URL instead of srcdoc for the outer iframe. + * Views that need same-origin access (e.g. to make fetch calls to ComfyUI API) + * must use blob URLs because srcdoc iframes always have a null origin. + * @param {string} contentType + * @returns {boolean} + */ +export function viewNeedsBlobUrl(contentType) { + const view = VIEW_REGISTRY.get(contentType); + if (view && typeof view.needsBlobUrl === 'function') { + return view.needsBlobUrl(); + } + return false; +} + +/** + * Get a direct URL for the outer Content Viewer iframe. + * Views that serve their own app (e.g. OpenReel) can provide a URL so the + * outer iframe loads it via src= instead of building HTML with srcdoc/blob. + * This avoids nested iframe issues entirely. + * @param {string} contentType + * @param {string} content - The raw display content (may contain JSON data) + * @param {object} theme - Theme tokens + * @returns {string|null} - URL to load, or null to use normal rendering + */ +export function getViewDirectUrl(contentType, content, theme) { + const view = VIEW_REGISTRY.get(contentType); + if (view && typeof view.getDirectUrl === 'function') { + return view.getDirectUrl(content, theme); + } + return null; +} + +/** + * Build a postMessage payload for sending updated content to an already-loaded + * directUrl iframe. Views that support this can update their content without + * a full iframe reload. + * @param {string} contentType + * @param {string} content - The raw display content + * @returns {object|null} - Message object to postMessage, or null if unsupported + */ +export function getViewContentMessage(contentType, content) { + const view = VIEW_REGISTRY.get(contentType); + if (view && typeof view.getContentMessage === 'function') { + return view.getContentMessage(content); + } + return null; +} + /** * Get display name for a view type * @param {string} contentType