Add APP Support

This commit is contained in:
Jordan Thompson
2026-02-16 22:56:21 -08:00
parent c486497e61
commit 91bb3e2ee9
22 changed files with 1650 additions and 625 deletions
+1
View File
@@ -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
+141 -65
View File
@@ -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"]
+501
View File
@@ -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 `
<div style="width: 100%; height: 100%; position: relative;">
<iframe
id="my-app-iframe"
src="${appUrl}"
style="width: 100%; height: 100%; border: none;"
allow="clipboard-write"
></iframe>
</div>
`;
}
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
+120 -111
View File
@@ -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
+1 -1
View File
@@ -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
+21 -21
View File
@@ -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
"""
+108 -98
View File
@@ -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:
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class CssParser(BaseParser):
"""CSS parser for CSS code display."""
PARSER_NAME = "css"
PARSER_PRIORITY = 10
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class CsvParser(BaseParser):
"""CSV parser for CSV content display."""
PARSER_NAME = "csv"
PARSER_PRIORITY = 10
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class HtmlParser(BaseParser):
"""HTML parser for HTML content display."""
PARSER_NAME = "html"
PARSER_PRIORITY = 10
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class JavaScriptParser(BaseParser):
"""JavaScript parser for JavaScript code display."""
PARSER_NAME = "javascript"
PARSER_PRIORITY = 10
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class JsonParser(BaseParser):
"""JSON parser for JSON content display."""
PARSER_NAME = "json"
PARSER_PRIORITY = 10
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class MarkdownParser(BaseParser):
"""Markdown parser for markdown content display."""
PARSER_NAME = "markdown"
PARSER_PRIORITY = 10
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class PythonParser(BaseParser):
"""Python parser for Python code display."""
PARSER_NAME = "python"
PARSER_PRIORITY = 10
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class SvgParser(BaseParser):
"""SVG parser for SVG content display."""
PARSER_NAME = "svg"
PARSER_PRIORITY = 10
+2 -2
View File
@@ -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
+1 -1
View File
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
class YamlParser(BaseParser):
"""YAML parser for YAML content display."""
PARSER_NAME = "yaml"
PARSER_PRIORITY = 10
+174 -70
View File
@@ -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()
+73
View File
@@ -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()
+154 -26
View File
@@ -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("<p style='opacity:0.5;text-align:center;margin-top:40px;'>Waiting for video content...</p>", "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;
+68
View File
@@ -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