Add cross-workflow image channel system with ImageReceiver node
Named channels allow PreviewToLoad to send images to a shared channel (stored in channels.json) that ImageReceiver nodes can read from, enabling cross-workflow image passing without brittle node IDs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
151
image_preview.py
151
image_preview.py
@@ -13,6 +13,55 @@ import node_helpers
|
|||||||
from comfy.cli_args import args
|
from comfy.cli_args import args
|
||||||
from comfy_execution.graph_utils import ExecutionBlocker
|
from comfy_execution.graph_utils import ExecutionBlocker
|
||||||
|
|
||||||
|
try:
|
||||||
|
from server import PromptServer
|
||||||
|
from aiohttp import web
|
||||||
|
except ImportError:
|
||||||
|
PromptServer = None
|
||||||
|
|
||||||
|
CHANNELS_FILE = os.path.join(os.path.dirname(__file__), "channels.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_channels():
|
||||||
|
if os.path.exists(CHANNELS_FILE):
|
||||||
|
try:
|
||||||
|
with open(CHANNELS_FILE, "r") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_channels(data):
|
||||||
|
with open(CHANNELS_FILE, "w") as f:
|
||||||
|
json.dump(data, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
if PromptServer is not None:
|
||||||
|
@PromptServer.instance.routes.post("/jdl/channel/send")
|
||||||
|
async def channel_send(request):
|
||||||
|
body = await request.json()
|
||||||
|
channel = body.get("channel", "default")
|
||||||
|
filename = body.get("filename", "")
|
||||||
|
if not filename:
|
||||||
|
return web.json_response({"error": "filename required"}, status=400)
|
||||||
|
channels = _read_channels()
|
||||||
|
channels[channel] = filename
|
||||||
|
_write_channels(channels)
|
||||||
|
return web.json_response({"ok": True, "channel": channel, "filename": filename})
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.get("/jdl/channel/receive")
|
||||||
|
async def channel_receive(request):
|
||||||
|
channel = request.query.get("channel", "default")
|
||||||
|
channels = _read_channels()
|
||||||
|
filename = channels.get(channel, "")
|
||||||
|
return web.json_response({"channel": channel, "filename": filename})
|
||||||
|
|
||||||
|
@PromptServer.instance.routes.get("/jdl/channel/list")
|
||||||
|
async def channel_list(request):
|
||||||
|
channels = _read_channels()
|
||||||
|
return web.json_response({"channels": list(channels.keys())})
|
||||||
|
|
||||||
|
|
||||||
class JDL_PreviewToLoad:
|
class JDL_PreviewToLoad:
|
||||||
"""Previews an image and saves a copy to input/ for use by LoadImage nodes."""
|
"""Previews an image and saves a copy to input/ for use by LoadImage nodes."""
|
||||||
@@ -120,30 +169,8 @@ class JDL_PreviewToLoad:
|
|||||||
return {"ui": {"images": results, "input_filename": [input_filename]}}
|
return {"ui": {"images": results, "input_filename": [input_filename]}}
|
||||||
|
|
||||||
|
|
||||||
class JDL_LoadImage:
|
def _load_image_from_path(image_path):
|
||||||
"""Load an image from the input directory with an active switch to skip downstream execution."""
|
"""Shared helper: load an image file and return (IMAGE tensor, MASK tensor)."""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def INPUT_TYPES(s):
|
|
||||||
input_dir = folder_paths.get_input_directory()
|
|
||||||
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
|
|
||||||
files = folder_paths.filter_files_content_types(files, ["image"])
|
|
||||||
return {
|
|
||||||
"required": {
|
|
||||||
"image": (sorted(files), {"image_upload": True}),
|
|
||||||
"active": ("BOOLEAN", {"default": True}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
RETURN_TYPES = ("IMAGE", "MASK")
|
|
||||||
FUNCTION = "load_image"
|
|
||||||
CATEGORY = "utils/image"
|
|
||||||
|
|
||||||
def load_image(self, image, active):
|
|
||||||
if not active:
|
|
||||||
return (ExecutionBlocker(None), ExecutionBlocker(None))
|
|
||||||
|
|
||||||
image_path = folder_paths.get_annotated_filepath(image)
|
|
||||||
img = node_helpers.pillow(Image.open, image_path)
|
img = node_helpers.pillow(Image.open, image_path)
|
||||||
|
|
||||||
output_images = []
|
output_images = []
|
||||||
@@ -189,6 +216,33 @@ class JDL_LoadImage:
|
|||||||
|
|
||||||
return (output_image, output_mask)
|
return (output_image, output_mask)
|
||||||
|
|
||||||
|
|
||||||
|
class JDL_LoadImage:
|
||||||
|
"""Load an image from the input directory with an active switch to skip downstream execution."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
input_dir = folder_paths.get_input_directory()
|
||||||
|
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
|
||||||
|
files = folder_paths.filter_files_content_types(files, ["image"])
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"image": (sorted(files), {"image_upload": True}),
|
||||||
|
"active": ("BOOLEAN", {"default": True}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE", "MASK")
|
||||||
|
FUNCTION = "load_image"
|
||||||
|
CATEGORY = "utils/image"
|
||||||
|
|
||||||
|
def load_image(self, image, active):
|
||||||
|
if not active:
|
||||||
|
return (ExecutionBlocker(None), ExecutionBlocker(None))
|
||||||
|
|
||||||
|
image_path = folder_paths.get_annotated_filepath(image)
|
||||||
|
return _load_image_from_path(image_path)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def IS_CHANGED(s, image, active):
|
def IS_CHANGED(s, image, active):
|
||||||
if not active:
|
if not active:
|
||||||
@@ -208,12 +262,61 @@ class JDL_LoadImage:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class JDL_ImageReceiver:
|
||||||
|
"""Load an image from a named channel (cross-workflow image passing)."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def INPUT_TYPES(s):
|
||||||
|
return {
|
||||||
|
"required": {
|
||||||
|
"channel": ("STRING", {"default": "default"}),
|
||||||
|
"active": ("BOOLEAN", {"default": True}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_TYPES = ("IMAGE", "MASK", "STRING")
|
||||||
|
RETURN_NAMES = ("image", "mask", "filename")
|
||||||
|
FUNCTION = "receive"
|
||||||
|
CATEGORY = "utils/image"
|
||||||
|
|
||||||
|
def receive(self, channel, active):
|
||||||
|
if not active:
|
||||||
|
return (ExecutionBlocker(None), ExecutionBlocker(None), ExecutionBlocker(None))
|
||||||
|
|
||||||
|
channels = _read_channels()
|
||||||
|
filename = channels.get(channel, "")
|
||||||
|
if not filename:
|
||||||
|
return (ExecutionBlocker(None), ExecutionBlocker(None), ExecutionBlocker(None))
|
||||||
|
|
||||||
|
input_dir = folder_paths.get_input_directory()
|
||||||
|
image_path = os.path.join(input_dir, filename)
|
||||||
|
if not os.path.isfile(image_path):
|
||||||
|
return (ExecutionBlocker(None), ExecutionBlocker(None), ExecutionBlocker(None))
|
||||||
|
|
||||||
|
output_image, output_mask = _load_image_from_path(image_path)
|
||||||
|
return (output_image, output_mask, filename)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def IS_CHANGED(s, channel, active):
|
||||||
|
if not active:
|
||||||
|
return "inactive"
|
||||||
|
channels = _read_channels()
|
||||||
|
filename = channels.get(channel, "")
|
||||||
|
return hashlib.sha256(filename.encode()).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(s, channel, active):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
NODE_CLASS_MAPPINGS = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"JDL_PreviewToLoad": JDL_PreviewToLoad,
|
"JDL_PreviewToLoad": JDL_PreviewToLoad,
|
||||||
"JDL_LoadImage": JDL_LoadImage,
|
"JDL_LoadImage": JDL_LoadImage,
|
||||||
|
"JDL_ImageReceiver": JDL_ImageReceiver,
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
"JDL_PreviewToLoad": "Preview to Load Image",
|
"JDL_PreviewToLoad": "Preview to Load Image",
|
||||||
"JDL_LoadImage": "Load Image (Active Switch)",
|
"JDL_LoadImage": "Load Image (Active Switch)",
|
||||||
|
"JDL_ImageReceiver": "Image Receiver (Channel)",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,34 @@ app.registerExtension({
|
|||||||
app.graph.setDirtyCanvas(true, true);
|
app.graph.setDirtyCanvas(true, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addWidget("text", "channel", "default", () => {});
|
||||||
|
|
||||||
|
this.addWidget("button", "Send to Channel", null, () => {
|
||||||
|
const channelWidget = this.widgets?.find(w => w.name === "channel");
|
||||||
|
const channel = channelWidget?.value || "default";
|
||||||
|
|
||||||
|
const filename = this.last_input_filename;
|
||||||
|
if (!filename) {
|
||||||
|
console.warn("[PreviewToLoad] No filename available. Run the workflow first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch("/jdl/channel/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ channel, filename }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.ok) {
|
||||||
|
console.log(`[PreviewToLoad] Sent "${filename}" to channel "${channel}"`);
|
||||||
|
} else {
|
||||||
|
console.warn("[PreviewToLoad] Channel send failed:", data.error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error("[PreviewToLoad] Channel send error:", err));
|
||||||
|
});
|
||||||
|
|
||||||
this.setSize(this.computeSize());
|
this.setSize(this.computeSize());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user