Add APP Support
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class CssParser(BaseParser):
|
||||
"""CSS parser for CSS code display."""
|
||||
|
||||
|
||||
PARSER_NAME = "css"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class CsvParser(BaseParser):
|
||||
"""CSV parser for CSV content display."""
|
||||
|
||||
|
||||
PARSER_NAME = "csv"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class HtmlParser(BaseParser):
|
||||
"""HTML parser for HTML content display."""
|
||||
|
||||
|
||||
PARSER_NAME = "html"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class JavaScriptParser(BaseParser):
|
||||
"""JavaScript parser for JavaScript code display."""
|
||||
|
||||
|
||||
PARSER_NAME = "javascript"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class JsonParser(BaseParser):
|
||||
"""JSON parser for JSON content display."""
|
||||
|
||||
|
||||
PARSER_NAME = "json"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class MarkdownParser(BaseParser):
|
||||
"""Markdown parser for markdown content display."""
|
||||
|
||||
|
||||
PARSER_NAME = "markdown"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
+277
-222
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class PythonParser(BaseParser):
|
||||
"""Python parser for Python code display."""
|
||||
|
||||
|
||||
PARSER_NAME = "python"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
@@ -8,6 +8,6 @@ from .base_parser import BaseParser
|
||||
|
||||
class SvgParser(BaseParser):
|
||||
"""SVG parser for SVG content display."""
|
||||
|
||||
|
||||
PARSER_NAME = "svg"
|
||||
PARSER_PRIORITY = 10
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user