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