From 866d8631289f688fd41bda9eeb989d49f8af80e8 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:37:52 -0800 Subject: [PATCH 01/12] adds support for executing simple glsl shaders using moderngl package --- comfy_extras/nodes_glsl.py | 425 +++++++++++++++++++++++++++++++++++++ nodes.py | 1 + requirements.txt | 1 + 3 files changed, 427 insertions(+) create mode 100644 comfy_extras/nodes_glsl.py diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py new file mode 100644 index 000000000..4da425131 --- /dev/null +++ b/comfy_extras/nodes_glsl.py @@ -0,0 +1,425 @@ +""" +GLSL Fragment Shader Node for ComfyUI using ModernGL. +Supports headless rendering with automatic software/CPU fallback. +""" + +import os +import re +import logging +from contextlib import contextmanager +from typing import TypedDict, Generator + +import numpy as np +import torch + +import nodes +from comfy_api.latest import ComfyExtension, io, ui +from comfy.cli_args import args +from typing_extensions import override +from utils.install_util import get_missing_requirements_message + + +class SizeModeInput(TypedDict): + size_mode: str + width: int + height: int + + +MAX_IMAGES = 5 # u_image0-4 +MAX_UNIFORMS = 5 # u_float0-4, u_int0-4 + +logger = logging.getLogger(__name__) + +try: + import moderngl +except ImportError as e: + raise RuntimeError(f"ModernGL is not available.\n{get_missing_requirements_message()}") from e + +# Default NOOP fragment shader that passes through the input image unchanged +DEFAULT_FRAGMENT_SHADER = """#version 300 es +precision highp float; + +uniform sampler2D u_image0; +uniform vec2 u_resolution; + +in vec2 v_texcoord; +out vec4 fragColor; + +void main() { + fragColor = texture(u_image0, v_texcoord); +} +""" + + +# Simple vertex shader for full-screen quad +VERTEX_SHADER = """#version 330 + +in vec2 in_position; +in vec2 in_texcoord; + +out vec2 v_texcoord; + +void main() { + gl_Position = vec4(in_position, 0.0, 1.0); + v_texcoord = in_texcoord; +} +""" + + +def _convert_es_to_desktop_glsl(source: str) -> str: + """Convert GLSL ES 3.00 shader to desktop GLSL 3.30 for ModernGL compatibility.""" + return re.sub(r'#version\s+300\s+es', '#version 330', source) + + +def _create_software_gl_context() -> moderngl.Context: + original_env = os.environ.get("LIBGL_ALWAYS_SOFTWARE") + os.environ["LIBGL_ALWAYS_SOFTWARE"] = "1" + try: + ctx = moderngl.create_standalone_context(require=330) + logger.info(f"Created software-rendered OpenGL context: {ctx.info['GL_RENDERER']}") + return ctx + finally: + if original_env is None: + os.environ.pop("LIBGL_ALWAYS_SOFTWARE", None) + else: + os.environ["LIBGL_ALWAYS_SOFTWARE"] = original_env + + +def _create_gl_context(force_software: bool = False) -> moderngl.Context: + if force_software: + try: + return _create_software_gl_context() + except Exception as e: + raise RuntimeError( + "Failed to create software-rendered OpenGL context.\n" + "Ensure Mesa/llvmpipe is installed for software rendering support." + ) from e + + # Try hardware rendering first, fall back to software + try: + ctx = moderngl.create_standalone_context(require=330) + logger.info(f"Created OpenGL context: {ctx.info['GL_RENDERER']}") + return ctx + except Exception as hw_error: + logger.warning(f"Hardware OpenGL context creation failed: {hw_error}") + logger.info("Attempting software rendering fallback...") + try: + return _create_software_gl_context() + except Exception as sw_error: + raise RuntimeError( + f"Failed to create OpenGL context.\n" + f"Hardware error: {hw_error}\n\n" + f"Possible solutions:\n" + f"1. Install GPU drivers with OpenGL 3.3+ support\n" + f"2. Install Mesa for software rendering (Linux: apt install libgl1-mesa-dri)\n" + f"3. On headless servers, ensure virtual framebuffer (Xvfb) or EGL is available" + ) from sw_error + + +def _image_to_texture(ctx: moderngl.Context, image: np.ndarray) -> moderngl.Texture: + height, width = image.shape[:2] + channels = image.shape[2] if len(image.shape) > 2 else 1 + + components = min(channels, 4) + + image_uint8 = (np.clip(image, 0, 1) * 255).astype(np.uint8) + + # Flip vertically for OpenGL coordinate system (origin at bottom-left) + image_uint8 = np.ascontiguousarray(np.flipud(image_uint8)) + + texture = ctx.texture((width, height), components, image_uint8.tobytes()) + texture.filter = (moderngl.LINEAR, moderngl.LINEAR) + texture.repeat_x = False + texture.repeat_y = False + + return texture + + +def _texture_to_image(fbo: moderngl.Framebuffer, channels: int = 4) -> np.ndarray: + width, height = fbo.size + + data = fbo.read(components=channels) + image = np.frombuffer(data, dtype=np.uint8).reshape((height, width, channels)) + + image = np.ascontiguousarray(np.flipud(image)) + + return image.astype(np.float32) / 255.0 + + +def _compile_shader(ctx: moderngl.Context, fragment_source: str) -> moderngl.Program: + # Convert user's GLSL ES 3.00 fragment shader to desktop GLSL 3.30 for ModernGL + fragment_source = _convert_es_to_desktop_glsl(fragment_source) + + try: + program = ctx.program( + vertex_shader=VERTEX_SHADER, + fragment_shader=fragment_source, + ) + return program + except Exception as e: + raise RuntimeError( + "Fragment shader compilation failed.\n\n" + "Make sure your shader:\n" + "1. Uses #version 300 es (WebGL 2.0 compatible)\n" + "2. Has valid GLSL ES 3.00 syntax\n" + "3. Includes 'precision highp float;' after version\n" + "4. Uses 'out vec4 fragColor' instead of gl_FragColor\n" + "5. Declares uniforms correctly (e.g., uniform sampler2D u_image0;)" + ) from e + + +def _render_shader( + ctx: moderngl.Context, + program: moderngl.Program, + width: int, + height: int, + textures: list[moderngl.Texture], + uniforms: dict[str, int | float], +) -> np.ndarray: + # Create output texture and framebuffer + output_texture = ctx.texture((width, height), 4) + output_texture.filter = (moderngl.LINEAR, moderngl.LINEAR) + fbo = ctx.framebuffer(color_attachments=[output_texture]) + + # Full-screen quad vertices (position + texcoord) + vertices = np.array([ + # Position (x, y), Texcoord (u, v) + -1.0, -1.0, 0.0, 0.0, + 1.0, -1.0, 1.0, 0.0, + -1.0, 1.0, 0.0, 1.0, + 1.0, 1.0, 1.0, 1.0, + ], dtype='f4') + + vbo = ctx.buffer(vertices.tobytes()) + vao = ctx.vertex_array( + program, + [(vbo, '2f 2f', 'in_position', 'in_texcoord')], + ) + + try: + # Bind textures + for i, texture in enumerate(textures): + texture.use(i) + uniform_name = f'u_image{i}' + if uniform_name in program: + program[uniform_name].value = i + + # Set uniforms + if 'u_resolution' in program: + program['u_resolution'].value = (float(width), float(height)) + + for name, value in uniforms.items(): + if name in program: + program[name].value = value + + # Render + fbo.use() + fbo.clear(0.0, 0.0, 0.0, 1.0) + vao.render(moderngl.TRIANGLE_STRIP) + + # Read result + return _texture_to_image(fbo, channels=4) + finally: + vao.release() + vbo.release() + output_texture.release() + fbo.release() + + +def _prepare_textures( + ctx: moderngl.Context, + image_list: list[torch.Tensor], + batch_idx: int, +) -> list[moderngl.Texture]: + textures = [] + for img_tensor in image_list[:MAX_IMAGES]: + img_idx = min(batch_idx, img_tensor.shape[0] - 1) + img_np = img_tensor[img_idx].cpu().numpy() + textures.append(_image_to_texture(ctx, img_np)) + return textures + + +def _prepare_uniforms(int_list: list[int], float_list: list[float]) -> dict[str, int | float]: + uniforms: dict[str, int | float] = {} + for i, val in enumerate(int_list[:MAX_UNIFORMS]): + uniforms[f'u_int{i}'] = int(val) + for i, val in enumerate(float_list[:MAX_UNIFORMS]): + uniforms[f'u_float{i}'] = float(val) + return uniforms + + +def _release_textures(textures: list[moderngl.Texture]) -> None: + for texture in textures: + texture.release() + + +@contextmanager +def _gl_context(force_software: bool = False) -> Generator[moderngl.Context, None, None]: + ctx = _create_gl_context(force_software) + try: + yield ctx + finally: + ctx.release() + + +@contextmanager +def _shader_program(ctx: moderngl.Context, fragment_source: str) -> Generator[moderngl.Program, None, None]: + program = _compile_shader(ctx, fragment_source) + try: + yield program + finally: + program.release() + + +@contextmanager +def _textures_context( + ctx: moderngl.Context, + image_list: list[torch.Tensor], + batch_idx: int, +) -> Generator[list[moderngl.Texture], None, None]: + textures = _prepare_textures(ctx, image_list, batch_idx) + try: + yield textures + finally: + _release_textures(textures) + + +class GLSLShader(io.ComfyNode): + + @classmethod + def define_schema(cls) -> io.Schema: + # Create autogrow templates + image_template = io.Autogrow.TemplatePrefix( + io.Image.Input("image"), + prefix="image", + min=1, + max=MAX_IMAGES, + ) + + float_template = io.Autogrow.TemplatePrefix( + io.Float.Input("float", default=0.0), + prefix="u_float", + min=0, + max=MAX_UNIFORMS, + ) + + int_template = io.Autogrow.TemplatePrefix( + io.Int.Input("int", default=0), + prefix="u_int", + min=0, + max=MAX_UNIFORMS, + ) + + return io.Schema( + node_id="GLSLShader", + display_name="GLSL Shader", + category="image/shader", + description=( + f"Apply GLSL fragment shaders to images. " + f"Uniforms: u_image0-{MAX_IMAGES-1} (sampler2D), u_resolution (vec2), " + f"u_float0-{MAX_UNIFORMS-1}, u_int0-{MAX_UNIFORMS-1}." + ), + inputs=[ + io.String.Input( + "fragment_shader", + default=DEFAULT_FRAGMENT_SHADER, + multiline=True, + tooltip="GLSL fragment shader source code (GLSL ES 3.00 / WebGL 2.0 compatible)", + ), + io.DynamicCombo.Input( + "size_mode", + options=[ + io.DynamicCombo.Option( + "from_input", + [], # No extra inputs - uses first input image dimensions + ), + io.DynamicCombo.Option( + "custom", + [ + io.Int.Input("width", default=512, min=1, max=nodes.MAX_RESOLUTION), + io.Int.Input("height", default=512, min=1, max=nodes.MAX_RESOLUTION), + ], + ), + ], + tooltip="Output size: 'from_input' uses first input image dimensions, 'custom' allows manual size", + ), + io.Autogrow.Input("images", template=image_template), + io.Autogrow.Input("floats", template=float_template), + io.Autogrow.Input("ints", template=int_template), + ], + outputs=[ + io.Image.Output(display_name="IMAGE"), + ], + ) + + @classmethod + def execute( + cls, + fragment_shader: str, + size_mode: SizeModeInput, + images: io.Autogrow.Type, + floats: io.Autogrow.Type = None, + ints: io.Autogrow.Type = None, + **kwargs, + ) -> io.NodeOutput: + image_list = [v for v in images.values() if v is not None] + float_list = [v if v is not None else 0.0 for v in floats.values()] if floats else [] + int_list = [v if v is not None else 0 for v in ints.values()] if ints else [] + + if not image_list: + raise ValueError("At least one input image is required") + + # Determine output dimensions + if size_mode["size_mode"] == "custom": + out_width, out_height = size_mode["width"], size_mode["height"] + else: + out_height, out_width = image_list[0].shape[1], image_list[0].shape[2] + + batch_size = image_list[0].shape[0] + uniforms = _prepare_uniforms(int_list, float_list) + + with _gl_context(force_software=args.cpu) as ctx: + with _shader_program(ctx, fragment_shader) as program: + output_images = [] + for b in range(batch_size): + with _textures_context(ctx, image_list, b) as textures: + result = _render_shader(ctx, program, out_width, out_height, textures, uniforms) + output_images.append(torch.from_numpy(result)) + + output_batch = torch.stack(output_images, dim=0) + if output_batch.shape[-1] == 4: + output_batch = output_batch[:, :, :, :3] + + return io.NodeOutput(output_batch, ui=cls._build_ui_output(image_list, output_batch)) + + @classmethod + def _build_ui_output(cls, image_list: list[torch.Tensor], output_batch: torch.Tensor) -> dict[str, list]: + """Build UI output with input and output images for client-side shader execution.""" + combined_inputs = torch.cat(image_list, dim=0) + input_images_ui = ui.ImageSaveHelper.save_images( + combined_inputs, + filename_prefix="GLSLShader_input", + folder_type=io.FolderType.temp, + cls=None, + compress_level=1, + ) + + output_images_ui = ui.ImageSaveHelper.save_images( + output_batch, + filename_prefix="GLSLShader_output", + folder_type=io.FolderType.temp, + cls=None, + compress_level=1, + ) + + return {"input_images": input_images_ui, "images": output_images_ui} + + +class GLSLExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [GLSLShader] + + +async def comfy_entrypoint() -> GLSLExtension: + return GLSLExtension() diff --git a/nodes.py b/nodes.py index 158106686..29e7776fc 100644 --- a/nodes.py +++ b/nodes.py @@ -2430,6 +2430,7 @@ async def init_builtin_extra_nodes(): "nodes_wanmove.py", "nodes_image_compare.py", "nodes_zimage.py", + "nodes_glsl.py", ] import_failed = [] diff --git a/requirements.txt b/requirements.txt index ec89dccd2..fa2393e19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,3 +28,4 @@ kornia>=0.7.1 spandrel pydantic~=2.0 pydantic-settings~=2.0 +moderngl From cc30293d65f4478236ae6ec8497b6e2dae04013c Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 23 Jan 2026 10:38:26 -0800 Subject: [PATCH 02/12] tidy --- comfy_extras/nodes_glsl.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 4da425131..6214c7aac 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -1,8 +1,3 @@ -""" -GLSL Fragment Shader Node for ComfyUI using ModernGL. -Supports headless rendering with automatic software/CPU fallback. -""" - import os import re import logging From b4438c9baf317700092557d5f19d8240ac8ee3ef Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Sat, 24 Jan 2026 12:55:06 -0800 Subject: [PATCH 03/12] Support multiple outputs --- comfy_extras/nodes_glsl.py | 67 ++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 24 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 6214c7aac..05cf7297a 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -20,8 +20,9 @@ class SizeModeInput(TypedDict): height: int -MAX_IMAGES = 5 # u_image0-4 -MAX_UNIFORMS = 5 # u_float0-4, u_int0-4 +MAX_IMAGES = 5 # u_image0-4 +MAX_UNIFORMS = 5 # u_float0-4, u_int0-4 +MAX_OUTPUTS = 4 # fragColor0-3 (MRT) logger = logging.getLogger(__name__) @@ -31,6 +32,7 @@ except ImportError as e: raise RuntimeError(f"ModernGL is not available.\n{get_missing_requirements_message()}") from e # Default NOOP fragment shader that passes through the input image unchanged +# For multiple outputs, use: layout(location = 0) out vec4 fragColor0; etc. DEFAULT_FRAGMENT_SHADER = """#version 300 es precision highp float; @@ -38,10 +40,10 @@ uniform sampler2D u_image0; uniform vec2 u_resolution; in vec2 v_texcoord; -out vec4 fragColor; +layout(location = 0) out vec4 fragColor0; void main() { - fragColor = texture(u_image0, v_texcoord); + fragColor0 = texture(u_image0, v_texcoord); } """ @@ -130,10 +132,10 @@ def _image_to_texture(ctx: moderngl.Context, image: np.ndarray) -> moderngl.Text return texture -def _texture_to_image(fbo: moderngl.Framebuffer, channels: int = 4) -> np.ndarray: +def _texture_to_image(fbo: moderngl.Framebuffer, attachment: int = 0, channels: int = 4) -> np.ndarray: width, height = fbo.size - data = fbo.read(components=channels) + data = fbo.read(components=channels, attachment=attachment) image = np.frombuffer(data, dtype=np.uint8).reshape((height, width, channels)) image = np.ascontiguousarray(np.flipud(image)) @@ -170,11 +172,15 @@ def _render_shader( height: int, textures: list[moderngl.Texture], uniforms: dict[str, int | float], -) -> np.ndarray: - # Create output texture and framebuffer - output_texture = ctx.texture((width, height), 4) - output_texture.filter = (moderngl.LINEAR, moderngl.LINEAR) - fbo = ctx.framebuffer(color_attachments=[output_texture]) +) -> list[np.ndarray]: + # Create output textures + output_textures = [] + for _ in range(MAX_OUTPUTS): + tex = ctx.texture((width, height), 4) + tex.filter = (moderngl.LINEAR, moderngl.LINEAR) + output_textures.append(tex) + + fbo = ctx.framebuffer(color_attachments=output_textures) # Full-screen quad vertices (position + texcoord) vertices = np.array([ @@ -212,12 +218,16 @@ def _render_shader( fbo.clear(0.0, 0.0, 0.0, 1.0) vao.render(moderngl.TRIANGLE_STRIP) - # Read result - return _texture_to_image(fbo, channels=4) + # Read results from all attachments + results = [] + for i in range(MAX_OUTPUTS): + results.append(_texture_to_image(fbo, attachment=i, channels=4)) + return results finally: vao.release() vbo.release() - output_texture.release() + for tex in output_textures: + tex.release() fbo.release() @@ -311,8 +321,9 @@ class GLSLShader(io.ComfyNode): category="image/shader", description=( f"Apply GLSL fragment shaders to images. " - f"Uniforms: u_image0-{MAX_IMAGES-1} (sampler2D), u_resolution (vec2), " - f"u_float0-{MAX_UNIFORMS-1}, u_int0-{MAX_UNIFORMS-1}." + f"Inputs: u_image0-{MAX_IMAGES-1} (sampler2D), u_resolution (vec2), " + f"u_float0-{MAX_UNIFORMS-1}, u_int0-{MAX_UNIFORMS-1}. " + f"Outputs: layout(location = 0-{MAX_OUTPUTS-1}) out vec4 fragColor0-{MAX_OUTPUTS-1}." ), inputs=[ io.String.Input( @@ -343,7 +354,10 @@ class GLSLShader(io.ComfyNode): io.Autogrow.Input("ints", template=int_template), ], outputs=[ - io.Image.Output(display_name="IMAGE"), + io.Image.Output(display_name="IMAGE0"), + io.Image.Output(display_name="IMAGE1"), + io.Image.Output(display_name="IMAGE2"), + io.Image.Output(display_name="IMAGE3"), ], ) @@ -375,17 +389,22 @@ class GLSLShader(io.ComfyNode): with _gl_context(force_software=args.cpu) as ctx: with _shader_program(ctx, fragment_shader) as program: - output_images = [] + # Collect outputs for each render target across all batches + all_outputs: list[list[torch.Tensor]] = [[] for _ in range(MAX_OUTPUTS)] + for b in range(batch_size): with _textures_context(ctx, image_list, b) as textures: - result = _render_shader(ctx, program, out_width, out_height, textures, uniforms) - output_images.append(torch.from_numpy(result)) + results = _render_shader(ctx, program, out_width, out_height, textures, uniforms) + for i, result in enumerate(results): + all_outputs[i].append(torch.from_numpy(result)) - output_batch = torch.stack(output_images, dim=0) - if output_batch.shape[-1] == 4: - output_batch = output_batch[:, :, :, :3] + # Stack batches for each output + output_values = [] + for i in range(MAX_OUTPUTS): + output_batch = torch.stack(all_outputs[i], dim=0) + output_values.append(output_batch) - return io.NodeOutput(output_batch, ui=cls._build_ui_output(image_list, output_batch)) + return io.NodeOutput(*output_values, ui=cls._build_ui_output(image_list, output_values[0])) @classmethod def _build_ui_output(cls, image_list: list[torch.Tensor], output_batch: torch.Tensor) -> dict[str, list]: From 9fa8202620f7e1c281bd4ed898eea2bdd08b6af1 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Sat, 24 Jan 2026 12:55:21 -0800 Subject: [PATCH 04/12] Try fix build --- .github/workflows/test-build.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 9160242e9..b7debe4ac 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -25,6 +25,10 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y libx11-dev - name: Install dependencies run: | python -m pip install --upgrade pip From 3da0e9c3671bd9535a53569a2c69f69207493342 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Wed, 28 Jan 2026 10:47:00 -0800 Subject: [PATCH 05/12] fix casing --- comfy_extras/nodes_glsl.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 05cf7297a..bd5bbd46f 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -39,11 +39,11 @@ precision highp float; uniform sampler2D u_image0; uniform vec2 u_resolution; -in vec2 v_texcoord; +in vec2 v_texCoord; layout(location = 0) out vec4 fragColor0; void main() { - fragColor0 = texture(u_image0, v_texcoord); + fragColor0 = texture(u_image0, v_texCoord); } """ @@ -54,11 +54,11 @@ VERTEX_SHADER = """#version 330 in vec2 in_position; in vec2 in_texcoord; -out vec2 v_texcoord; +out vec2 v_texCoord; void main() { gl_Position = vec4(in_position, 0.0, 1.0); - v_texcoord = in_texcoord; + v_texCoord = in_texcoord; } """ From aaea976f36f513d82cb5181d9382bcb7460a260d Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Wed, 28 Jan 2026 11:02:17 -0800 Subject: [PATCH 06/12] fix line endings --- comfy_extras/nodes_glsl.py | 878 ++++++++++++++++++------------------- 1 file changed, 439 insertions(+), 439 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index bd5bbd46f..021b3f6a7 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -1,439 +1,439 @@ -import os -import re -import logging -from contextlib import contextmanager -from typing import TypedDict, Generator - -import numpy as np -import torch - -import nodes -from comfy_api.latest import ComfyExtension, io, ui -from comfy.cli_args import args -from typing_extensions import override -from utils.install_util import get_missing_requirements_message - - -class SizeModeInput(TypedDict): - size_mode: str - width: int - height: int - - -MAX_IMAGES = 5 # u_image0-4 -MAX_UNIFORMS = 5 # u_float0-4, u_int0-4 -MAX_OUTPUTS = 4 # fragColor0-3 (MRT) - -logger = logging.getLogger(__name__) - -try: - import moderngl -except ImportError as e: - raise RuntimeError(f"ModernGL is not available.\n{get_missing_requirements_message()}") from e - -# Default NOOP fragment shader that passes through the input image unchanged -# For multiple outputs, use: layout(location = 0) out vec4 fragColor0; etc. -DEFAULT_FRAGMENT_SHADER = """#version 300 es -precision highp float; - -uniform sampler2D u_image0; -uniform vec2 u_resolution; - -in vec2 v_texCoord; -layout(location = 0) out vec4 fragColor0; - -void main() { - fragColor0 = texture(u_image0, v_texCoord); -} -""" - - -# Simple vertex shader for full-screen quad -VERTEX_SHADER = """#version 330 - -in vec2 in_position; -in vec2 in_texcoord; - -out vec2 v_texCoord; - -void main() { - gl_Position = vec4(in_position, 0.0, 1.0); - v_texCoord = in_texcoord; -} -""" - - -def _convert_es_to_desktop_glsl(source: str) -> str: - """Convert GLSL ES 3.00 shader to desktop GLSL 3.30 for ModernGL compatibility.""" - return re.sub(r'#version\s+300\s+es', '#version 330', source) - - -def _create_software_gl_context() -> moderngl.Context: - original_env = os.environ.get("LIBGL_ALWAYS_SOFTWARE") - os.environ["LIBGL_ALWAYS_SOFTWARE"] = "1" - try: - ctx = moderngl.create_standalone_context(require=330) - logger.info(f"Created software-rendered OpenGL context: {ctx.info['GL_RENDERER']}") - return ctx - finally: - if original_env is None: - os.environ.pop("LIBGL_ALWAYS_SOFTWARE", None) - else: - os.environ["LIBGL_ALWAYS_SOFTWARE"] = original_env - - -def _create_gl_context(force_software: bool = False) -> moderngl.Context: - if force_software: - try: - return _create_software_gl_context() - except Exception as e: - raise RuntimeError( - "Failed to create software-rendered OpenGL context.\n" - "Ensure Mesa/llvmpipe is installed for software rendering support." - ) from e - - # Try hardware rendering first, fall back to software - try: - ctx = moderngl.create_standalone_context(require=330) - logger.info(f"Created OpenGL context: {ctx.info['GL_RENDERER']}") - return ctx - except Exception as hw_error: - logger.warning(f"Hardware OpenGL context creation failed: {hw_error}") - logger.info("Attempting software rendering fallback...") - try: - return _create_software_gl_context() - except Exception as sw_error: - raise RuntimeError( - f"Failed to create OpenGL context.\n" - f"Hardware error: {hw_error}\n\n" - f"Possible solutions:\n" - f"1. Install GPU drivers with OpenGL 3.3+ support\n" - f"2. Install Mesa for software rendering (Linux: apt install libgl1-mesa-dri)\n" - f"3. On headless servers, ensure virtual framebuffer (Xvfb) or EGL is available" - ) from sw_error - - -def _image_to_texture(ctx: moderngl.Context, image: np.ndarray) -> moderngl.Texture: - height, width = image.shape[:2] - channels = image.shape[2] if len(image.shape) > 2 else 1 - - components = min(channels, 4) - - image_uint8 = (np.clip(image, 0, 1) * 255).astype(np.uint8) - - # Flip vertically for OpenGL coordinate system (origin at bottom-left) - image_uint8 = np.ascontiguousarray(np.flipud(image_uint8)) - - texture = ctx.texture((width, height), components, image_uint8.tobytes()) - texture.filter = (moderngl.LINEAR, moderngl.LINEAR) - texture.repeat_x = False - texture.repeat_y = False - - return texture - - -def _texture_to_image(fbo: moderngl.Framebuffer, attachment: int = 0, channels: int = 4) -> np.ndarray: - width, height = fbo.size - - data = fbo.read(components=channels, attachment=attachment) - image = np.frombuffer(data, dtype=np.uint8).reshape((height, width, channels)) - - image = np.ascontiguousarray(np.flipud(image)) - - return image.astype(np.float32) / 255.0 - - -def _compile_shader(ctx: moderngl.Context, fragment_source: str) -> moderngl.Program: - # Convert user's GLSL ES 3.00 fragment shader to desktop GLSL 3.30 for ModernGL - fragment_source = _convert_es_to_desktop_glsl(fragment_source) - - try: - program = ctx.program( - vertex_shader=VERTEX_SHADER, - fragment_shader=fragment_source, - ) - return program - except Exception as e: - raise RuntimeError( - "Fragment shader compilation failed.\n\n" - "Make sure your shader:\n" - "1. Uses #version 300 es (WebGL 2.0 compatible)\n" - "2. Has valid GLSL ES 3.00 syntax\n" - "3. Includes 'precision highp float;' after version\n" - "4. Uses 'out vec4 fragColor' instead of gl_FragColor\n" - "5. Declares uniforms correctly (e.g., uniform sampler2D u_image0;)" - ) from e - - -def _render_shader( - ctx: moderngl.Context, - program: moderngl.Program, - width: int, - height: int, - textures: list[moderngl.Texture], - uniforms: dict[str, int | float], -) -> list[np.ndarray]: - # Create output textures - output_textures = [] - for _ in range(MAX_OUTPUTS): - tex = ctx.texture((width, height), 4) - tex.filter = (moderngl.LINEAR, moderngl.LINEAR) - output_textures.append(tex) - - fbo = ctx.framebuffer(color_attachments=output_textures) - - # Full-screen quad vertices (position + texcoord) - vertices = np.array([ - # Position (x, y), Texcoord (u, v) - -1.0, -1.0, 0.0, 0.0, - 1.0, -1.0, 1.0, 0.0, - -1.0, 1.0, 0.0, 1.0, - 1.0, 1.0, 1.0, 1.0, - ], dtype='f4') - - vbo = ctx.buffer(vertices.tobytes()) - vao = ctx.vertex_array( - program, - [(vbo, '2f 2f', 'in_position', 'in_texcoord')], - ) - - try: - # Bind textures - for i, texture in enumerate(textures): - texture.use(i) - uniform_name = f'u_image{i}' - if uniform_name in program: - program[uniform_name].value = i - - # Set uniforms - if 'u_resolution' in program: - program['u_resolution'].value = (float(width), float(height)) - - for name, value in uniforms.items(): - if name in program: - program[name].value = value - - # Render - fbo.use() - fbo.clear(0.0, 0.0, 0.0, 1.0) - vao.render(moderngl.TRIANGLE_STRIP) - - # Read results from all attachments - results = [] - for i in range(MAX_OUTPUTS): - results.append(_texture_to_image(fbo, attachment=i, channels=4)) - return results - finally: - vao.release() - vbo.release() - for tex in output_textures: - tex.release() - fbo.release() - - -def _prepare_textures( - ctx: moderngl.Context, - image_list: list[torch.Tensor], - batch_idx: int, -) -> list[moderngl.Texture]: - textures = [] - for img_tensor in image_list[:MAX_IMAGES]: - img_idx = min(batch_idx, img_tensor.shape[0] - 1) - img_np = img_tensor[img_idx].cpu().numpy() - textures.append(_image_to_texture(ctx, img_np)) - return textures - - -def _prepare_uniforms(int_list: list[int], float_list: list[float]) -> dict[str, int | float]: - uniforms: dict[str, int | float] = {} - for i, val in enumerate(int_list[:MAX_UNIFORMS]): - uniforms[f'u_int{i}'] = int(val) - for i, val in enumerate(float_list[:MAX_UNIFORMS]): - uniforms[f'u_float{i}'] = float(val) - return uniforms - - -def _release_textures(textures: list[moderngl.Texture]) -> None: - for texture in textures: - texture.release() - - -@contextmanager -def _gl_context(force_software: bool = False) -> Generator[moderngl.Context, None, None]: - ctx = _create_gl_context(force_software) - try: - yield ctx - finally: - ctx.release() - - -@contextmanager -def _shader_program(ctx: moderngl.Context, fragment_source: str) -> Generator[moderngl.Program, None, None]: - program = _compile_shader(ctx, fragment_source) - try: - yield program - finally: - program.release() - - -@contextmanager -def _textures_context( - ctx: moderngl.Context, - image_list: list[torch.Tensor], - batch_idx: int, -) -> Generator[list[moderngl.Texture], None, None]: - textures = _prepare_textures(ctx, image_list, batch_idx) - try: - yield textures - finally: - _release_textures(textures) - - -class GLSLShader(io.ComfyNode): - - @classmethod - def define_schema(cls) -> io.Schema: - # Create autogrow templates - image_template = io.Autogrow.TemplatePrefix( - io.Image.Input("image"), - prefix="image", - min=1, - max=MAX_IMAGES, - ) - - float_template = io.Autogrow.TemplatePrefix( - io.Float.Input("float", default=0.0), - prefix="u_float", - min=0, - max=MAX_UNIFORMS, - ) - - int_template = io.Autogrow.TemplatePrefix( - io.Int.Input("int", default=0), - prefix="u_int", - min=0, - max=MAX_UNIFORMS, - ) - - return io.Schema( - node_id="GLSLShader", - display_name="GLSL Shader", - category="image/shader", - description=( - f"Apply GLSL fragment shaders to images. " - f"Inputs: u_image0-{MAX_IMAGES-1} (sampler2D), u_resolution (vec2), " - f"u_float0-{MAX_UNIFORMS-1}, u_int0-{MAX_UNIFORMS-1}. " - f"Outputs: layout(location = 0-{MAX_OUTPUTS-1}) out vec4 fragColor0-{MAX_OUTPUTS-1}." - ), - inputs=[ - io.String.Input( - "fragment_shader", - default=DEFAULT_FRAGMENT_SHADER, - multiline=True, - tooltip="GLSL fragment shader source code (GLSL ES 3.00 / WebGL 2.0 compatible)", - ), - io.DynamicCombo.Input( - "size_mode", - options=[ - io.DynamicCombo.Option( - "from_input", - [], # No extra inputs - uses first input image dimensions - ), - io.DynamicCombo.Option( - "custom", - [ - io.Int.Input("width", default=512, min=1, max=nodes.MAX_RESOLUTION), - io.Int.Input("height", default=512, min=1, max=nodes.MAX_RESOLUTION), - ], - ), - ], - tooltip="Output size: 'from_input' uses first input image dimensions, 'custom' allows manual size", - ), - io.Autogrow.Input("images", template=image_template), - io.Autogrow.Input("floats", template=float_template), - io.Autogrow.Input("ints", template=int_template), - ], - outputs=[ - io.Image.Output(display_name="IMAGE0"), - io.Image.Output(display_name="IMAGE1"), - io.Image.Output(display_name="IMAGE2"), - io.Image.Output(display_name="IMAGE3"), - ], - ) - - @classmethod - def execute( - cls, - fragment_shader: str, - size_mode: SizeModeInput, - images: io.Autogrow.Type, - floats: io.Autogrow.Type = None, - ints: io.Autogrow.Type = None, - **kwargs, - ) -> io.NodeOutput: - image_list = [v for v in images.values() if v is not None] - float_list = [v if v is not None else 0.0 for v in floats.values()] if floats else [] - int_list = [v if v is not None else 0 for v in ints.values()] if ints else [] - - if not image_list: - raise ValueError("At least one input image is required") - - # Determine output dimensions - if size_mode["size_mode"] == "custom": - out_width, out_height = size_mode["width"], size_mode["height"] - else: - out_height, out_width = image_list[0].shape[1], image_list[0].shape[2] - - batch_size = image_list[0].shape[0] - uniforms = _prepare_uniforms(int_list, float_list) - - with _gl_context(force_software=args.cpu) as ctx: - with _shader_program(ctx, fragment_shader) as program: - # Collect outputs for each render target across all batches - all_outputs: list[list[torch.Tensor]] = [[] for _ in range(MAX_OUTPUTS)] - - for b in range(batch_size): - with _textures_context(ctx, image_list, b) as textures: - results = _render_shader(ctx, program, out_width, out_height, textures, uniforms) - for i, result in enumerate(results): - all_outputs[i].append(torch.from_numpy(result)) - - # Stack batches for each output - output_values = [] - for i in range(MAX_OUTPUTS): - output_batch = torch.stack(all_outputs[i], dim=0) - output_values.append(output_batch) - - return io.NodeOutput(*output_values, ui=cls._build_ui_output(image_list, output_values[0])) - - @classmethod - def _build_ui_output(cls, image_list: list[torch.Tensor], output_batch: torch.Tensor) -> dict[str, list]: - """Build UI output with input and output images for client-side shader execution.""" - combined_inputs = torch.cat(image_list, dim=0) - input_images_ui = ui.ImageSaveHelper.save_images( - combined_inputs, - filename_prefix="GLSLShader_input", - folder_type=io.FolderType.temp, - cls=None, - compress_level=1, - ) - - output_images_ui = ui.ImageSaveHelper.save_images( - output_batch, - filename_prefix="GLSLShader_output", - folder_type=io.FolderType.temp, - cls=None, - compress_level=1, - ) - - return {"input_images": input_images_ui, "images": output_images_ui} - - -class GLSLExtension(ComfyExtension): - @override - async def get_node_list(self) -> list[type[io.ComfyNode]]: - return [GLSLShader] - - -async def comfy_entrypoint() -> GLSLExtension: - return GLSLExtension() +import os +import re +import logging +from contextlib import contextmanager +from typing import TypedDict, Generator + +import numpy as np +import torch + +import nodes +from comfy_api.latest import ComfyExtension, io, ui +from comfy.cli_args import args +from typing_extensions import override +from utils.install_util import get_missing_requirements_message + + +class SizeModeInput(TypedDict): + size_mode: str + width: int + height: int + + +MAX_IMAGES = 5 # u_image0-4 +MAX_UNIFORMS = 5 # u_float0-4, u_int0-4 +MAX_OUTPUTS = 4 # fragColor0-3 (MRT) + +logger = logging.getLogger(__name__) + +try: + import moderngl +except ImportError as e: + raise RuntimeError(f"ModernGL is not available.\n{get_missing_requirements_message()}") from e + +# Default NOOP fragment shader that passes through the input image unchanged +# For multiple outputs, use: layout(location = 0) out vec4 fragColor0; etc. +DEFAULT_FRAGMENT_SHADER = """#version 300 es +precision highp float; + +uniform sampler2D u_image0; +uniform vec2 u_resolution; + +in vec2 v_texCoord; +layout(location = 0) out vec4 fragColor0; + +void main() { + fragColor0 = texture(u_image0, v_texCoord); +} +""" + + +# Simple vertex shader for full-screen quad +VERTEX_SHADER = """#version 330 + +in vec2 in_position; +in vec2 in_texcoord; + +out vec2 v_texCoord; + +void main() { + gl_Position = vec4(in_position, 0.0, 1.0); + v_texCoord = in_texcoord; +} +""" + + +def _convert_es_to_desktop_glsl(source: str) -> str: + """Convert GLSL ES 3.00 shader to desktop GLSL 3.30 for ModernGL compatibility.""" + return re.sub(r'#version\s+300\s+es', '#version 330', source) + + +def _create_software_gl_context() -> moderngl.Context: + original_env = os.environ.get("LIBGL_ALWAYS_SOFTWARE") + os.environ["LIBGL_ALWAYS_SOFTWARE"] = "1" + try: + ctx = moderngl.create_standalone_context(require=330) + logger.info(f"Created software-rendered OpenGL context: {ctx.info['GL_RENDERER']}") + return ctx + finally: + if original_env is None: + os.environ.pop("LIBGL_ALWAYS_SOFTWARE", None) + else: + os.environ["LIBGL_ALWAYS_SOFTWARE"] = original_env + + +def _create_gl_context(force_software: bool = False) -> moderngl.Context: + if force_software: + try: + return _create_software_gl_context() + except Exception as e: + raise RuntimeError( + "Failed to create software-rendered OpenGL context.\n" + "Ensure Mesa/llvmpipe is installed for software rendering support." + ) from e + + # Try hardware rendering first, fall back to software + try: + ctx = moderngl.create_standalone_context(require=330) + logger.info(f"Created OpenGL context: {ctx.info['GL_RENDERER']}") + return ctx + except Exception as hw_error: + logger.warning(f"Hardware OpenGL context creation failed: {hw_error}") + logger.info("Attempting software rendering fallback...") + try: + return _create_software_gl_context() + except Exception as sw_error: + raise RuntimeError( + f"Failed to create OpenGL context.\n" + f"Hardware error: {hw_error}\n\n" + f"Possible solutions:\n" + f"1. Install GPU drivers with OpenGL 3.3+ support\n" + f"2. Install Mesa for software rendering (Linux: apt install libgl1-mesa-dri)\n" + f"3. On headless servers, ensure virtual framebuffer (Xvfb) or EGL is available" + ) from sw_error + + +def _image_to_texture(ctx: moderngl.Context, image: np.ndarray) -> moderngl.Texture: + height, width = image.shape[:2] + channels = image.shape[2] if len(image.shape) > 2 else 1 + + components = min(channels, 4) + + image_uint8 = (np.clip(image, 0, 1) * 255).astype(np.uint8) + + # Flip vertically for OpenGL coordinate system (origin at bottom-left) + image_uint8 = np.ascontiguousarray(np.flipud(image_uint8)) + + texture = ctx.texture((width, height), components, image_uint8.tobytes()) + texture.filter = (moderngl.LINEAR, moderngl.LINEAR) + texture.repeat_x = False + texture.repeat_y = False + + return texture + + +def _texture_to_image(fbo: moderngl.Framebuffer, attachment: int = 0, channels: int = 4) -> np.ndarray: + width, height = fbo.size + + data = fbo.read(components=channels, attachment=attachment) + image = np.frombuffer(data, dtype=np.uint8).reshape((height, width, channels)) + + image = np.ascontiguousarray(np.flipud(image)) + + return image.astype(np.float32) / 255.0 + + +def _compile_shader(ctx: moderngl.Context, fragment_source: str) -> moderngl.Program: + # Convert user's GLSL ES 3.00 fragment shader to desktop GLSL 3.30 for ModernGL + fragment_source = _convert_es_to_desktop_glsl(fragment_source) + + try: + program = ctx.program( + vertex_shader=VERTEX_SHADER, + fragment_shader=fragment_source, + ) + return program + except Exception as e: + raise RuntimeError( + "Fragment shader compilation failed.\n\n" + "Make sure your shader:\n" + "1. Uses #version 300 es (WebGL 2.0 compatible)\n" + "2. Has valid GLSL ES 3.00 syntax\n" + "3. Includes 'precision highp float;' after version\n" + "4. Uses 'out vec4 fragColor' instead of gl_FragColor\n" + "5. Declares uniforms correctly (e.g., uniform sampler2D u_image0;)" + ) from e + + +def _render_shader( + ctx: moderngl.Context, + program: moderngl.Program, + width: int, + height: int, + textures: list[moderngl.Texture], + uniforms: dict[str, int | float], +) -> list[np.ndarray]: + # Create output textures + output_textures = [] + for _ in range(MAX_OUTPUTS): + tex = ctx.texture((width, height), 4) + tex.filter = (moderngl.LINEAR, moderngl.LINEAR) + output_textures.append(tex) + + fbo = ctx.framebuffer(color_attachments=output_textures) + + # Full-screen quad vertices (position + texcoord) + vertices = np.array([ + # Position (x, y), Texcoord (u, v) + -1.0, -1.0, 0.0, 0.0, + 1.0, -1.0, 1.0, 0.0, + -1.0, 1.0, 0.0, 1.0, + 1.0, 1.0, 1.0, 1.0, + ], dtype='f4') + + vbo = ctx.buffer(vertices.tobytes()) + vao = ctx.vertex_array( + program, + [(vbo, '2f 2f', 'in_position', 'in_texcoord')], + ) + + try: + # Bind textures + for i, texture in enumerate(textures): + texture.use(i) + uniform_name = f'u_image{i}' + if uniform_name in program: + program[uniform_name].value = i + + # Set uniforms + if 'u_resolution' in program: + program['u_resolution'].value = (float(width), float(height)) + + for name, value in uniforms.items(): + if name in program: + program[name].value = value + + # Render + fbo.use() + fbo.clear(0.0, 0.0, 0.0, 1.0) + vao.render(moderngl.TRIANGLE_STRIP) + + # Read results from all attachments + results = [] + for i in range(MAX_OUTPUTS): + results.append(_texture_to_image(fbo, attachment=i, channels=4)) + return results + finally: + vao.release() + vbo.release() + for tex in output_textures: + tex.release() + fbo.release() + + +def _prepare_textures( + ctx: moderngl.Context, + image_list: list[torch.Tensor], + batch_idx: int, +) -> list[moderngl.Texture]: + textures = [] + for img_tensor in image_list[:MAX_IMAGES]: + img_idx = min(batch_idx, img_tensor.shape[0] - 1) + img_np = img_tensor[img_idx].cpu().numpy() + textures.append(_image_to_texture(ctx, img_np)) + return textures + + +def _prepare_uniforms(int_list: list[int], float_list: list[float]) -> dict[str, int | float]: + uniforms: dict[str, int | float] = {} + for i, val in enumerate(int_list[:MAX_UNIFORMS]): + uniforms[f'u_int{i}'] = int(val) + for i, val in enumerate(float_list[:MAX_UNIFORMS]): + uniforms[f'u_float{i}'] = float(val) + return uniforms + + +def _release_textures(textures: list[moderngl.Texture]) -> None: + for texture in textures: + texture.release() + + +@contextmanager +def _gl_context(force_software: bool = False) -> Generator[moderngl.Context, None, None]: + ctx = _create_gl_context(force_software) + try: + yield ctx + finally: + ctx.release() + + +@contextmanager +def _shader_program(ctx: moderngl.Context, fragment_source: str) -> Generator[moderngl.Program, None, None]: + program = _compile_shader(ctx, fragment_source) + try: + yield program + finally: + program.release() + + +@contextmanager +def _textures_context( + ctx: moderngl.Context, + image_list: list[torch.Tensor], + batch_idx: int, +) -> Generator[list[moderngl.Texture], None, None]: + textures = _prepare_textures(ctx, image_list, batch_idx) + try: + yield textures + finally: + _release_textures(textures) + + +class GLSLShader(io.ComfyNode): + + @classmethod + def define_schema(cls) -> io.Schema: + # Create autogrow templates + image_template = io.Autogrow.TemplatePrefix( + io.Image.Input("image"), + prefix="image", + min=1, + max=MAX_IMAGES, + ) + + float_template = io.Autogrow.TemplatePrefix( + io.Float.Input("float", default=0.0), + prefix="u_float", + min=0, + max=MAX_UNIFORMS, + ) + + int_template = io.Autogrow.TemplatePrefix( + io.Int.Input("int", default=0), + prefix="u_int", + min=0, + max=MAX_UNIFORMS, + ) + + return io.Schema( + node_id="GLSLShader", + display_name="GLSL Shader", + category="image/shader", + description=( + f"Apply GLSL fragment shaders to images. " + f"Inputs: u_image0-{MAX_IMAGES-1} (sampler2D), u_resolution (vec2), " + f"u_float0-{MAX_UNIFORMS-1}, u_int0-{MAX_UNIFORMS-1}. " + f"Outputs: layout(location = 0-{MAX_OUTPUTS-1}) out vec4 fragColor0-{MAX_OUTPUTS-1}." + ), + inputs=[ + io.String.Input( + "fragment_shader", + default=DEFAULT_FRAGMENT_SHADER, + multiline=True, + tooltip="GLSL fragment shader source code (GLSL ES 3.00 / WebGL 2.0 compatible)", + ), + io.DynamicCombo.Input( + "size_mode", + options=[ + io.DynamicCombo.Option( + "from_input", + [], # No extra inputs - uses first input image dimensions + ), + io.DynamicCombo.Option( + "custom", + [ + io.Int.Input("width", default=512, min=1, max=nodes.MAX_RESOLUTION), + io.Int.Input("height", default=512, min=1, max=nodes.MAX_RESOLUTION), + ], + ), + ], + tooltip="Output size: 'from_input' uses first input image dimensions, 'custom' allows manual size", + ), + io.Autogrow.Input("images", template=image_template), + io.Autogrow.Input("floats", template=float_template), + io.Autogrow.Input("ints", template=int_template), + ], + outputs=[ + io.Image.Output(display_name="IMAGE0"), + io.Image.Output(display_name="IMAGE1"), + io.Image.Output(display_name="IMAGE2"), + io.Image.Output(display_name="IMAGE3"), + ], + ) + + @classmethod + def execute( + cls, + fragment_shader: str, + size_mode: SizeModeInput, + images: io.Autogrow.Type, + floats: io.Autogrow.Type = None, + ints: io.Autogrow.Type = None, + **kwargs, + ) -> io.NodeOutput: + image_list = [v for v in images.values() if v is not None] + float_list = [v if v is not None else 0.0 for v in floats.values()] if floats else [] + int_list = [v if v is not None else 0 for v in ints.values()] if ints else [] + + if not image_list: + raise ValueError("At least one input image is required") + + # Determine output dimensions + if size_mode["size_mode"] == "custom": + out_width, out_height = size_mode["width"], size_mode["height"] + else: + out_height, out_width = image_list[0].shape[1], image_list[0].shape[2] + + batch_size = image_list[0].shape[0] + uniforms = _prepare_uniforms(int_list, float_list) + + with _gl_context(force_software=args.cpu) as ctx: + with _shader_program(ctx, fragment_shader) as program: + # Collect outputs for each render target across all batches + all_outputs: list[list[torch.Tensor]] = [[] for _ in range(MAX_OUTPUTS)] + + for b in range(batch_size): + with _textures_context(ctx, image_list, b) as textures: + results = _render_shader(ctx, program, out_width, out_height, textures, uniforms) + for i, result in enumerate(results): + all_outputs[i].append(torch.from_numpy(result)) + + # Stack batches for each output + output_values = [] + for i in range(MAX_OUTPUTS): + output_batch = torch.stack(all_outputs[i], dim=0) + output_values.append(output_batch) + + return io.NodeOutput(*output_values, ui=cls._build_ui_output(image_list, output_values[0])) + + @classmethod + def _build_ui_output(cls, image_list: list[torch.Tensor], output_batch: torch.Tensor) -> dict[str, list]: + """Build UI output with input and output images for client-side shader execution.""" + combined_inputs = torch.cat(image_list, dim=0) + input_images_ui = ui.ImageSaveHelper.save_images( + combined_inputs, + filename_prefix="GLSLShader_input", + folder_type=io.FolderType.temp, + cls=None, + compress_level=1, + ) + + output_images_ui = ui.ImageSaveHelper.save_images( + output_batch, + filename_prefix="GLSLShader_output", + folder_type=io.FolderType.temp, + cls=None, + compress_level=1, + ) + + return {"input_images": input_images_ui, "images": output_images_ui} + + +class GLSLExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [GLSLShader] + + +async def comfy_entrypoint() -> GLSLExtension: + return GLSLExtension() From a4317314d2c095fdec779984ab46992eecaf4fe7 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:48:20 -0800 Subject: [PATCH 07/12] convert to using PyOpenGL and glfw --- .github/workflows/test-build.yml | 4 - comfy_extras/nodes_glsl.py | 603 ++++++++++++++++++------------- requirements.txt | 4 +- 3 files changed, 347 insertions(+), 264 deletions(-) diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index b7debe4ac..9160242e9 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -25,10 +25,6 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y libx11-dev - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 021b3f6a7..f4d84c2f9 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -1,18 +1,59 @@ import os + +from comfy.cli_args import args + +if args.cpu: + os.environ.setdefault("PYOPENGL_PLATFORM", "osmesa") +elif not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): + os.environ.setdefault("PYOPENGL_PLATFORM", "egl") + import re import logging -from contextlib import contextmanager -from typing import TypedDict, Generator +from typing import TypedDict import numpy as np import torch import nodes from comfy_api.latest import ComfyExtension, io, ui -from comfy.cli_args import args from typing_extensions import override from utils.install_util import get_missing_requirements_message +logger = logging.getLogger(__name__) + +try: + import glfw + import OpenGL.GL as gl +except ImportError as e: + raise RuntimeError( + f"OpenGL dependencies not available.\n{get_missing_requirements_message()}\n" + "Install with: pip install PyOpenGL PyOpenGL-accelerate glfw" + ) from e +except AttributeError as e: + # This happens when PyOpenGL can't load the requested platform (e.g., OSMesa not installed) + platform = os.environ.get("PYOPENGL_PLATFORM", "default") + if platform == "osmesa": + raise RuntimeError( + "OSMesa (software rendering) requested but not installed.\n" + "OSMesa is required for --cpu mode.\n\n" + "Install OSMesa:\n" + " e.g. Ubuntu/Debian: sudo apt install libosmesa6-dev\n" + "Or disable CPU mode to use hardware rendering." + ) from e + elif platform == "egl": + raise RuntimeError( + "EGL (headless rendering) requested but not available.\n" + "EGL is used for headless GPU rendering without a display.\n\n" + "Install EGL:\n" + " e.g. Ubuntu/Debian: sudo apt install libegl1-mesa-dev libgles2-mesa-dev\n" + "Or set DISPLAY/WAYLAND_DISPLAY environment variable if you have a display." + ) from e + else: + raise RuntimeError( + f"OpenGL initialization failed (platform: {platform}).\n" + "Ensure OpenGL drivers are installed and working." + ) from e + class SizeModeInput(TypedDict): size_mode: str @@ -24,15 +65,25 @@ MAX_IMAGES = 5 # u_image0-4 MAX_UNIFORMS = 5 # u_float0-4, u_int0-4 MAX_OUTPUTS = 4 # fragColor0-3 (MRT) -logger = logging.getLogger(__name__) +# Vertex shader using gl_VertexID trick - no VBO needed. +# Draws a single triangle that covers the entire screen: +# +# (-1,3) +# /| +# / | <- visible area is the unit square from (-1,-1) to (1,1) +# / | parts outside get clipped away +# (-1,-1)---(3,-1) +# +# v_texCoord is computed from clip space: * 0.5 + 0.5 maps (-1,1) -> (0,1) +VERTEX_SHADER = """#version 330 core +out vec2 v_texCoord; +void main() { + vec2 verts[3] = vec2[](vec2(-1, -1), vec2(3, -1), vec2(-1, 3)); + v_texCoord = verts[gl_VertexID] * 0.5 + 0.5; + gl_Position = vec4(verts[gl_VertexID], 0, 1); +} +""" -try: - import moderngl -except ImportError as e: - raise RuntimeError(f"ModernGL is not available.\n{get_missing_requirements_message()}") from e - -# Default NOOP fragment shader that passes through the input image unchanged -# For multiple outputs, use: layout(location = 0) out vec4 fragColor0; etc. DEFAULT_FRAGMENT_SHADER = """#version 300 es precision highp float; @@ -48,252 +99,267 @@ void main() { """ -# Simple vertex shader for full-screen quad -VERTEX_SHADER = """#version 330 - -in vec2 in_position; -in vec2 in_texcoord; - -out vec2 v_texCoord; - -void main() { - gl_Position = vec4(in_position, 0.0, 1.0); - v_texCoord = in_texcoord; -} -""" +def _convert_es_to_desktop(source: str) -> str: + """Convert GLSL ES (WebGL) shader source to desktop GLSL 330 core.""" + # Remove any existing #version directive + source = re.sub(r"#version\s+\d+(\s+es)?\s*\n?", "", source, flags=re.IGNORECASE) + # Remove precision qualifiers (not needed in desktop GLSL) + source = re.sub(r"precision\s+(lowp|mediump|highp)\s+\w+\s*;\s*\n?", "", source) + # Prepend desktop GLSL version + return "#version 330 core\n" + source -def _convert_es_to_desktop_glsl(source: str) -> str: - """Convert GLSL ES 3.00 shader to desktop GLSL 3.30 for ModernGL compatibility.""" - return re.sub(r'#version\s+300\s+es', '#version 330', source) +class GLContext: + """Manages OpenGL context and resources for shader execution.""" + + _instance = None + _initialized = False + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if GLContext._initialized: + return + GLContext._initialized = True + + import time + start = time.perf_counter() + + if not glfw.init(): + raise RuntimeError("Failed to initialize GLFW") + + glfw.window_hint(glfw.VISIBLE, glfw.FALSE) + glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) + glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) + glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) + + self._window = glfw.create_window(64, 64, "ComfyUI GLSL", None, None) + if not self._window: + glfw.terminate() + raise RuntimeError("Failed to create GLFW window") + + glfw.make_context_current(self._window) + + # Create VAO (required for core profile even if we don't use vertex attributes) + self._vao = gl.glGenVertexArrays(1) + gl.glBindVertexArray(self._vao) + + elapsed = (time.perf_counter() - start) * 1000 + + # Log device info + renderer = gl.glGetString(gl.GL_RENDERER) + vendor = gl.glGetString(gl.GL_VENDOR) + version = gl.glGetString(gl.GL_VERSION) + renderer = renderer.decode() if renderer else "Unknown" + vendor = vendor.decode() if vendor else "Unknown" + version = version.decode() if version else "Unknown" + + logger.info(f"GLSL context initialized in {elapsed:.1f}ms - {renderer} ({vendor}), GL {version}") + + def make_current(self): + glfw.make_context_current(self._window) + gl.glBindVertexArray(self._vao) -def _create_software_gl_context() -> moderngl.Context: - original_env = os.environ.get("LIBGL_ALWAYS_SOFTWARE") - os.environ["LIBGL_ALWAYS_SOFTWARE"] = "1" +def _compile_shader(source: str, shader_type: int) -> int: + """Compile a shader and return its ID.""" + shader = gl.glCreateShader(shader_type) + gl.glShaderSource(shader, source) + gl.glCompileShader(shader) + + if gl.glGetShaderiv(shader, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: + error = gl.glGetShaderInfoLog(shader).decode() + gl.glDeleteShader(shader) + raise RuntimeError(f"Shader compilation failed:\n{error}") + + return shader + + +def _create_program(vertex_source: str, fragment_source: str) -> int: + """Create and link a shader program.""" + vertex_shader = _compile_shader(vertex_source, gl.GL_VERTEX_SHADER) try: - ctx = moderngl.create_standalone_context(require=330) - logger.info(f"Created software-rendered OpenGL context: {ctx.info['GL_RENDERER']}") - return ctx - finally: - if original_env is None: - os.environ.pop("LIBGL_ALWAYS_SOFTWARE", None) - else: - os.environ["LIBGL_ALWAYS_SOFTWARE"] = original_env + fragment_shader = _compile_shader(fragment_source, gl.GL_FRAGMENT_SHADER) + except RuntimeError: + gl.glDeleteShader(vertex_shader) + raise + + program = gl.glCreateProgram() + gl.glAttachShader(program, vertex_shader) + gl.glAttachShader(program, fragment_shader) + gl.glLinkProgram(program) + + gl.glDeleteShader(vertex_shader) + gl.glDeleteShader(fragment_shader) + + if gl.glGetProgramiv(program, gl.GL_LINK_STATUS) != gl.GL_TRUE: + error = gl.glGetProgramInfoLog(program).decode() + gl.glDeleteProgram(program) + raise RuntimeError(f"Program linking failed:\n{error}") + + return program -def _create_gl_context(force_software: bool = False) -> moderngl.Context: - if force_software: - try: - return _create_software_gl_context() - except Exception as e: - raise RuntimeError( - "Failed to create software-rendered OpenGL context.\n" - "Ensure Mesa/llvmpipe is installed for software rendering support." - ) from e - - # Try hardware rendering first, fall back to software - try: - ctx = moderngl.create_standalone_context(require=330) - logger.info(f"Created OpenGL context: {ctx.info['GL_RENDERER']}") - return ctx - except Exception as hw_error: - logger.warning(f"Hardware OpenGL context creation failed: {hw_error}") - logger.info("Attempting software rendering fallback...") - try: - return _create_software_gl_context() - except Exception as sw_error: - raise RuntimeError( - f"Failed to create OpenGL context.\n" - f"Hardware error: {hw_error}\n\n" - f"Possible solutions:\n" - f"1. Install GPU drivers with OpenGL 3.3+ support\n" - f"2. Install Mesa for software rendering (Linux: apt install libgl1-mesa-dri)\n" - f"3. On headless servers, ensure virtual framebuffer (Xvfb) or EGL is available" - ) from sw_error - - -def _image_to_texture(ctx: moderngl.Context, image: np.ndarray) -> moderngl.Texture: - height, width = image.shape[:2] - channels = image.shape[2] if len(image.shape) > 2 else 1 - - components = min(channels, 4) - - image_uint8 = (np.clip(image, 0, 1) * 255).astype(np.uint8) - - # Flip vertically for OpenGL coordinate system (origin at bottom-left) - image_uint8 = np.ascontiguousarray(np.flipud(image_uint8)) - - texture = ctx.texture((width, height), components, image_uint8.tobytes()) - texture.filter = (moderngl.LINEAR, moderngl.LINEAR) - texture.repeat_x = False - texture.repeat_y = False - - return texture - - -def _texture_to_image(fbo: moderngl.Framebuffer, attachment: int = 0, channels: int = 4) -> np.ndarray: - width, height = fbo.size - - data = fbo.read(components=channels, attachment=attachment) - image = np.frombuffer(data, dtype=np.uint8).reshape((height, width, channels)) - - image = np.ascontiguousarray(np.flipud(image)) - - return image.astype(np.float32) / 255.0 - - -def _compile_shader(ctx: moderngl.Context, fragment_source: str) -> moderngl.Program: - # Convert user's GLSL ES 3.00 fragment shader to desktop GLSL 3.30 for ModernGL - fragment_source = _convert_es_to_desktop_glsl(fragment_source) - - try: - program = ctx.program( - vertex_shader=VERTEX_SHADER, - fragment_shader=fragment_source, - ) - return program - except Exception as e: - raise RuntimeError( - "Fragment shader compilation failed.\n\n" - "Make sure your shader:\n" - "1. Uses #version 300 es (WebGL 2.0 compatible)\n" - "2. Has valid GLSL ES 3.00 syntax\n" - "3. Includes 'precision highp float;' after version\n" - "4. Uses 'out vec4 fragColor' instead of gl_FragColor\n" - "5. Declares uniforms correctly (e.g., uniform sampler2D u_image0;)" - ) from e - - -def _render_shader( - ctx: moderngl.Context, - program: moderngl.Program, +def _render_shader_batch( + fragment_code: str, width: int, height: int, - textures: list[moderngl.Texture], - uniforms: dict[str, int | float], -) -> list[np.ndarray]: - # Create output textures + image_batches: list[list[np.ndarray]], + floats: list[float], + ints: list[int], +) -> list[list[np.ndarray]]: + """ + Render a fragment shader for multiple batches efficiently. + + Compiles shader once, reuses framebuffer/textures across batches. + + Args: + fragment_code: User's fragment shader code + width: Output width + height: Output height + image_batches: List of batches, each batch is a list of input images (H, W, C) float32 [0,1] + floats: List of float uniforms + ints: List of int uniforms + + Returns: + List of batch outputs, each is a list of output images (H, W, 4) float32 [0,1] + """ + if not image_batches: + return [] + + ctx = GLContext() + ctx.make_current() + + # Convert from GLSL ES to desktop GLSL 330 + fragment_source = _convert_es_to_desktop(fragment_code) + + # Track resources for cleanup + program = None + fbo = None output_textures = [] - for _ in range(MAX_OUTPUTS): - tex = ctx.texture((width, height), 4) - tex.filter = (moderngl.LINEAR, moderngl.LINEAR) - output_textures.append(tex) + input_textures = [] - fbo = ctx.framebuffer(color_attachments=output_textures) - - # Full-screen quad vertices (position + texcoord) - vertices = np.array([ - # Position (x, y), Texcoord (u, v) - -1.0, -1.0, 0.0, 0.0, - 1.0, -1.0, 1.0, 0.0, - -1.0, 1.0, 0.0, 1.0, - 1.0, 1.0, 1.0, 1.0, - ], dtype='f4') - - vbo = ctx.buffer(vertices.tobytes()) - vao = ctx.vertex_array( - program, - [(vbo, '2f 2f', 'in_position', 'in_texcoord')], - ) + num_inputs = len(image_batches[0]) try: - # Bind textures - for i, texture in enumerate(textures): - texture.use(i) - uniform_name = f'u_image{i}' - if uniform_name in program: - program[uniform_name].value = i + # Compile shaders (once for all batches) + try: + program = _create_program(VERTEX_SHADER, fragment_source) + except RuntimeError: + logger.error(f"Fragment shader:\n{fragment_source}") + raise - # Set uniforms - if 'u_resolution' in program: - program['u_resolution'].value = (float(width), float(height)) + gl.glUseProgram(program) - for name, value in uniforms.items(): - if name in program: - program[name].value = value + # Create framebuffer with multiple color attachments (reused for all batches) + fbo = gl.glGenFramebuffers(1) + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo) - # Render - fbo.use() - fbo.clear(0.0, 0.0, 0.0, 1.0) - vao.render(moderngl.TRIANGLE_STRIP) - - # Read results from all attachments - results = [] + draw_buffers = [] for i in range(MAX_OUTPUTS): - results.append(_texture_to_image(fbo, attachment=i, channels=4)) - return results + tex = gl.glGenTextures(1) + output_textures.append(tex) + gl.glBindTexture(gl.GL_TEXTURE_2D, tex) + gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA32F, width, height, 0, gl.GL_RGBA, gl.GL_FLOAT, None) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) + gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0 + i, gl.GL_TEXTURE_2D, tex, 0) + draw_buffers.append(gl.GL_COLOR_ATTACHMENT0 + i) + + gl.glDrawBuffers(MAX_OUTPUTS, draw_buffers) + + if gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) != gl.GL_FRAMEBUFFER_COMPLETE: + raise RuntimeError("Framebuffer is not complete") + + # Create input textures (reused for all batches) + for i in range(num_inputs): + tex = gl.glGenTextures(1) + input_textures.append(tex) + gl.glActiveTexture(gl.GL_TEXTURE0 + i) + gl.glBindTexture(gl.GL_TEXTURE_2D, tex) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_EDGE) + gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_EDGE) + + loc = gl.glGetUniformLocation(program, f"u_image{i}") + if loc >= 0: + gl.glUniform1i(loc, i) + + # Set static uniforms (once for all batches) + loc = gl.glGetUniformLocation(program, "u_resolution") + if loc >= 0: + gl.glUniform2f(loc, float(width), float(height)) + + for i, v in enumerate(floats): + loc = gl.glGetUniformLocation(program, f"u_float{i}") + if loc >= 0: + gl.glUniform1f(loc, v) + + for i, v in enumerate(ints): + loc = gl.glGetUniformLocation(program, f"u_int{i}") + if loc >= 0: + gl.glUniform1i(loc, v) + + gl.glViewport(0, 0, width, height) + gl.glDisable(gl.GL_BLEND) # Ensure no alpha blending - write output directly + + # Process each batch + all_batch_outputs = [] + for images in image_batches: + # Update input textures with this batch's images + for i, img in enumerate(images): + gl.glActiveTexture(gl.GL_TEXTURE0 + i) + gl.glBindTexture(gl.GL_TEXTURE_2D, input_textures[i]) + + # Flip vertically for GL coordinates, ensure RGBA + img_flipped = np.ascontiguousarray(img[::-1, :, :]) + if img_flipped.shape[2] == 3: + img_flipped = np.ascontiguousarray(np.concatenate( + [img_flipped, np.ones((*img_flipped.shape[:2], 1), dtype=np.float32)], + axis=2, + )) + + gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA32F, img_flipped.shape[1], img_flipped.shape[0], 0, gl.GL_RGBA, gl.GL_FLOAT, img_flipped) + + # Render + gl.glClearColor(0, 0, 0, 0) + gl.glClear(gl.GL_COLOR_BUFFER_BIT) + gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3) + + # Read back outputs for this batch + batch_outputs = [] + for tex in output_textures: + gl.glBindTexture(gl.GL_TEXTURE_2D, tex) + data = gl.glGetTexImage(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, gl.GL_FLOAT) + img = np.frombuffer(data, dtype=np.float32).reshape(height, width, 4) + batch_outputs.append(np.ascontiguousarray(img[::-1, :, :])) + + all_batch_outputs.append(batch_outputs) + + return all_batch_outputs + finally: - vao.release() - vbo.release() - for tex in output_textures: - tex.release() - fbo.release() - - -def _prepare_textures( - ctx: moderngl.Context, - image_list: list[torch.Tensor], - batch_idx: int, -) -> list[moderngl.Texture]: - textures = [] - for img_tensor in image_list[:MAX_IMAGES]: - img_idx = min(batch_idx, img_tensor.shape[0] - 1) - img_np = img_tensor[img_idx].cpu().numpy() - textures.append(_image_to_texture(ctx, img_np)) - return textures - - -def _prepare_uniforms(int_list: list[int], float_list: list[float]) -> dict[str, int | float]: - uniforms: dict[str, int | float] = {} - for i, val in enumerate(int_list[:MAX_UNIFORMS]): - uniforms[f'u_int{i}'] = int(val) - for i, val in enumerate(float_list[:MAX_UNIFORMS]): - uniforms[f'u_float{i}'] = float(val) - return uniforms - - -def _release_textures(textures: list[moderngl.Texture]) -> None: - for texture in textures: - texture.release() - - -@contextmanager -def _gl_context(force_software: bool = False) -> Generator[moderngl.Context, None, None]: - ctx = _create_gl_context(force_software) - try: - yield ctx - finally: - ctx.release() - - -@contextmanager -def _shader_program(ctx: moderngl.Context, fragment_source: str) -> Generator[moderngl.Program, None, None]: - program = _compile_shader(ctx, fragment_source) - try: - yield program - finally: - program.release() - - -@contextmanager -def _textures_context( - ctx: moderngl.Context, - image_list: list[torch.Tensor], - batch_idx: int, -) -> Generator[list[moderngl.Texture], None, None]: - textures = _prepare_textures(ctx, image_list, batch_idx) - try: - yield textures - finally: - _release_textures(textures) + # Unbind before deleting + gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, 0) + gl.glUseProgram(0) + if input_textures: + gl.glDeleteTextures(len(input_textures), input_textures) + if output_textures: + gl.glDeleteTextures(len(output_textures), output_textures) + if fbo is not None: + gl.glDeleteFramebuffers(1, [fbo]) + if program is not None: + gl.glDeleteProgram(program) class GLSLShader(io.ComfyNode): @classmethod def define_schema(cls) -> io.Schema: - # Create autogrow templates image_template = io.Autogrow.TemplatePrefix( io.Image.Input("image"), prefix="image", @@ -335,15 +401,22 @@ class GLSLShader(io.ComfyNode): io.DynamicCombo.Input( "size_mode", options=[ - io.DynamicCombo.Option( - "from_input", - [], # No extra inputs - uses first input image dimensions - ), + io.DynamicCombo.Option("from_input", []), io.DynamicCombo.Option( "custom", [ - io.Int.Input("width", default=512, min=1, max=nodes.MAX_RESOLUTION), - io.Int.Input("height", default=512, min=1, max=nodes.MAX_RESOLUTION), + io.Int.Input( + "width", + default=512, + min=1, + max=nodes.MAX_RESOLUTION, + ), + io.Int.Input( + "height", + default=512, + min=1, + max=nodes.MAX_RESOLUTION, + ), ], ), ], @@ -372,7 +445,9 @@ class GLSLShader(io.ComfyNode): **kwargs, ) -> io.NodeOutput: image_list = [v for v in images.values() if v is not None] - float_list = [v if v is not None else 0.0 for v in floats.values()] if floats else [] + float_list = ( + [v if v is not None else 0.0 for v in floats.values()] if floats else [] + ) int_list = [v if v is not None else 0 for v in ints.values()] if ints else [] if not image_list: @@ -380,34 +455,44 @@ class GLSLShader(io.ComfyNode): # Determine output dimensions if size_mode["size_mode"] == "custom": - out_width, out_height = size_mode["width"], size_mode["height"] + out_width = size_mode["width"] + out_height = size_mode["height"] else: - out_height, out_width = image_list[0].shape[1], image_list[0].shape[2] + out_height, out_width = image_list[0].shape[1:3] batch_size = image_list[0].shape[0] - uniforms = _prepare_uniforms(int_list, float_list) - with _gl_context(force_software=args.cpu) as ctx: - with _shader_program(ctx, fragment_shader) as program: - # Collect outputs for each render target across all batches - all_outputs: list[list[torch.Tensor]] = [[] for _ in range(MAX_OUTPUTS)] + # Prepare batches + image_batches = [] + for batch_idx in range(batch_size): + batch_images = [img_tensor[batch_idx].cpu().numpy().astype(np.float32) for img_tensor in image_list] + image_batches.append(batch_images) - for b in range(batch_size): - with _textures_context(ctx, image_list, b) as textures: - results = _render_shader(ctx, program, out_width, out_height, textures, uniforms) - for i, result in enumerate(results): - all_outputs[i].append(torch.from_numpy(result)) + all_batch_outputs = _render_shader_batch( + fragment_shader, + out_width, + out_height, + image_batches, + float_list, + int_list, + ) - # Stack batches for each output - output_values = [] - for i in range(MAX_OUTPUTS): - output_batch = torch.stack(all_outputs[i], dim=0) - output_values.append(output_batch) + # Collect outputs into tensors + all_outputs = [[] for _ in range(MAX_OUTPUTS)] + for batch_outputs in all_batch_outputs: + for i, out_img in enumerate(batch_outputs): + all_outputs[i].append(torch.from_numpy(out_img)) - return io.NodeOutput(*output_values, ui=cls._build_ui_output(image_list, output_values[0])) + output_tensors = [torch.stack(all_outputs[i], dim=0) for i in range(MAX_OUTPUTS)] + return io.NodeOutput( + *output_tensors, + ui=cls._build_ui_output(image_list, output_tensors[0]), + ) @classmethod - def _build_ui_output(cls, image_list: list[torch.Tensor], output_batch: torch.Tensor) -> dict[str, list]: + def _build_ui_output( + cls, image_list: list[torch.Tensor], output_batch: torch.Tensor + ) -> dict[str, list]: """Build UI output with input and output images for client-side shader execution.""" combined_inputs = torch.cat(image_list, dim=0) input_images_ui = ui.ImageSaveHelper.save_images( diff --git a/requirements.txt b/requirements.txt index b29d429ac..b25a79ab0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -29,4 +29,6 @@ kornia>=0.7.1 spandrel pydantic~=2.0 pydantic-settings~=2.0 -moderngl +PyOpenGL +PyOpenGL-accelerate +glfw From d809ef8fb1c3eb1a7076a5bd1e918308fba63ce8 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:58:04 -0800 Subject: [PATCH 08/12] remove cpu support --- comfy_extras/nodes_glsl.py | 38 ++++++++------------------------------ 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index f4d84c2f9..7280acc23 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -1,12 +1,5 @@ import os -from comfy.cli_args import args - -if args.cpu: - os.environ.setdefault("PYOPENGL_PLATFORM", "osmesa") -elif not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): - os.environ.setdefault("PYOPENGL_PLATFORM", "egl") - import re import logging from typing import TypedDict @@ -30,29 +23,14 @@ except ImportError as e: "Install with: pip install PyOpenGL PyOpenGL-accelerate glfw" ) from e except AttributeError as e: - # This happens when PyOpenGL can't load the requested platform (e.g., OSMesa not installed) - platform = os.environ.get("PYOPENGL_PLATFORM", "default") - if platform == "osmesa": - raise RuntimeError( - "OSMesa (software rendering) requested but not installed.\n" - "OSMesa is required for --cpu mode.\n\n" - "Install OSMesa:\n" - " e.g. Ubuntu/Debian: sudo apt install libosmesa6-dev\n" - "Or disable CPU mode to use hardware rendering." - ) from e - elif platform == "egl": - raise RuntimeError( - "EGL (headless rendering) requested but not available.\n" - "EGL is used for headless GPU rendering without a display.\n\n" - "Install EGL:\n" - " e.g. Ubuntu/Debian: sudo apt install libegl1-mesa-dev libgles2-mesa-dev\n" - "Or set DISPLAY/WAYLAND_DISPLAY environment variable if you have a display." - ) from e - else: - raise RuntimeError( - f"OpenGL initialization failed (platform: {platform}).\n" - "Ensure OpenGL drivers are installed and working." - ) from e + # This happens when PyOpenGL can't initialize (e.g., no display, missing libraries) + raise RuntimeError( + "OpenGL initialization failed.\n" + "Ensure OpenGL drivers are installed and a display is available.\n\n" + "For headless servers, you may need:\n" + " - EGL: sudo apt install libegl1-mesa-dev\n" + " - Or a virtual display: Xvfb :99 & export DISPLAY=:99" + ) from e class SizeModeInput(TypedDict): From 23572c6314edeeca1a66f70e08c3a84ecaa657c8 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Wed, 28 Jan 2026 20:59:01 -0800 Subject: [PATCH 09/12] tidy --- comfy_extras/nodes_glsl.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 7280acc23..61118920c 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -1,5 +1,3 @@ -import os - import re import logging from typing import TypedDict From 1263d6fe889930aa6436daeb03b988c1bc75169b Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:07:40 -0800 Subject: [PATCH 10/12] add additional support for egl & osmesa backends --- comfy_extras/nodes_glsl.py | 315 ++++++++++++++++++++++++++++++++----- 1 file changed, 273 insertions(+), 42 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 61118920c..170d1d786 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -1,5 +1,9 @@ +import os +import sys import re import logging +import ctypes.util +import importlib.util from typing import TypedDict import numpy as np @@ -12,23 +16,55 @@ from utils.install_util import get_missing_requirements_message logger = logging.getLogger(__name__) -try: - import glfw - import OpenGL.GL as gl -except ImportError as e: - raise RuntimeError( - f"OpenGL dependencies not available.\n{get_missing_requirements_message()}\n" - "Install with: pip install PyOpenGL PyOpenGL-accelerate glfw" - ) from e -except AttributeError as e: - # This happens when PyOpenGL can't initialize (e.g., no display, missing libraries) - raise RuntimeError( - "OpenGL initialization failed.\n" - "Ensure OpenGL drivers are installed and a display is available.\n\n" - "For headless servers, you may need:\n" - " - EGL: sudo apt install libegl1-mesa-dev\n" - " - Or a virtual display: Xvfb :99 & export DISPLAY=:99" - ) from e + +def _check_opengl_availability(): + """Early check for OpenGL availability. Raises RuntimeError if unlikely to work.""" + missing = [] + + # Check Python packages (using find_spec to avoid importing) + if importlib.util.find_spec("glfw") is None: + missing.append("glfw") + + if importlib.util.find_spec("OpenGL") is None: + missing.append("PyOpenGL") + + if missing: + raise RuntimeError( + f"OpenGL dependencies not available.\n{get_missing_requirements_message()}\n" + ) + + # On Linux without display, check if headless backends are available + if sys.platform.startswith("linux"): + has_display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") + if not has_display: + # Check for EGL or OSMesa libraries + has_egl = ctypes.util.find_library("EGL") + has_osmesa = ctypes.util.find_library("OSMesa") + + if not has_egl and not has_osmesa: + raise RuntimeError( + "GLSL Shader node: No display and no headless backend (EGL/OSMesa) found.\n" + "See error below for installation instructions." + ) + logger.debug(f"Headless mode: EGL={'yes' if has_egl else 'no'}, OSMesa={'yes' if has_osmesa else 'no'}") + + +# Run early check at import time +_check_opengl_availability() + +# OpenGL modules - initialized lazily when context is created +gl = None +glfw = None +EGL = None + + +def _import_opengl(): + """Import OpenGL module. Called after context is created.""" + global gl + if gl is None: + import OpenGL.GL as _gl + gl = _gl + return gl class SizeModeInput(TypedDict): @@ -85,8 +121,134 @@ def _convert_es_to_desktop(source: str) -> str: return "#version 330 core\n" + source +def _init_glfw(): + """Initialize GLFW. Returns (window, glfw_module). Raises RuntimeError on failure.""" + import glfw as _glfw + + if not _glfw.init(): + raise RuntimeError("glfw.init() failed") + + try: + _glfw.window_hint(_glfw.VISIBLE, _glfw.FALSE) + _glfw.window_hint(_glfw.CONTEXT_VERSION_MAJOR, 3) + _glfw.window_hint(_glfw.CONTEXT_VERSION_MINOR, 3) + _glfw.window_hint(_glfw.OPENGL_PROFILE, _glfw.OPENGL_CORE_PROFILE) + + window = _glfw.create_window(64, 64, "ComfyUI GLSL", None, None) + if not window: + raise RuntimeError("glfw.create_window() failed") + + _glfw.make_context_current(window) + return window, _glfw + except Exception: + _glfw.terminate() + raise + + +def _init_egl(): + """Initialize EGL for headless rendering. Returns (display, context, surface, EGL_module). Raises RuntimeError on failure.""" + from OpenGL import EGL as _EGL + from OpenGL.EGL import ( + eglGetDisplay, eglInitialize, eglChooseConfig, eglCreateContext, + eglMakeCurrent, eglCreatePbufferSurface, eglBindAPI, + eglTerminate, eglDestroyContext, eglDestroySurface, + EGL_DEFAULT_DISPLAY, EGL_NO_CONTEXT, EGL_NONE, + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, + EGL_RED_SIZE, EGL_GREEN_SIZE, EGL_BLUE_SIZE, EGL_ALPHA_SIZE, EGL_DEPTH_SIZE, + EGL_WIDTH, EGL_HEIGHT, EGL_OPENGL_API, + ) + + display = None + context = None + surface = None + + try: + display = eglGetDisplay(EGL_DEFAULT_DISPLAY) + if display == _EGL.EGL_NO_DISPLAY: + raise RuntimeError("eglGetDisplay() failed") + + major, minor = _EGL.EGLint(), _EGL.EGLint() + if not eglInitialize(display, major, minor): + display = None # Not initialized, don't terminate + raise RuntimeError("eglInitialize() failed") + + config_attribs = [ + EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, + EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8, + EGL_DEPTH_SIZE, 0, EGL_NONE + ] + configs = (_EGL.EGLConfig * 1)() + num_configs = _EGL.EGLint() + if not eglChooseConfig(display, config_attribs, configs, 1, num_configs) or num_configs.value == 0: + raise RuntimeError("eglChooseConfig() failed") + config = configs[0] + + if not eglBindAPI(EGL_OPENGL_API): + raise RuntimeError("eglBindAPI() failed") + + context_attribs = [ + _EGL.EGL_CONTEXT_MAJOR_VERSION, 3, + _EGL.EGL_CONTEXT_MINOR_VERSION, 3, + _EGL.EGL_CONTEXT_OPENGL_PROFILE_MASK, _EGL.EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT, + EGL_NONE + ] + context = eglCreateContext(display, config, EGL_NO_CONTEXT, context_attribs) + if context == EGL_NO_CONTEXT: + raise RuntimeError("eglCreateContext() failed") + + pbuffer_attribs = [EGL_WIDTH, 64, EGL_HEIGHT, 64, EGL_NONE] + surface = eglCreatePbufferSurface(display, config, pbuffer_attribs) + if surface == _EGL.EGL_NO_SURFACE: + raise RuntimeError("eglCreatePbufferSurface() failed") + + if not eglMakeCurrent(display, surface, surface, context): + raise RuntimeError("eglMakeCurrent() failed") + + return display, context, surface, _EGL + + except Exception: + # Clean up any resources on failure + if surface is not None: + eglDestroySurface(display, surface) + if context is not None: + eglDestroyContext(display, context) + if display is not None: + eglTerminate(display) + raise + + +def _init_osmesa(): + """Initialize OSMesa for software rendering. Returns (context, buffer). Raises RuntimeError on failure.""" + import ctypes + + os.environ["PYOPENGL_PLATFORM"] = "osmesa" + + from OpenGL import GL as _gl + from OpenGL.osmesa import ( + OSMesaCreateContextExt, OSMesaMakeCurrent, OSMesaDestroyContext, + OSMESA_RGBA, + ) + + ctx = OSMesaCreateContextExt(OSMESA_RGBA, 24, 0, 0, None) + if not ctx: + raise RuntimeError("OSMesaCreateContextExt() failed") + + width, height = 64, 64 + buffer = (ctypes.c_ubyte * (width * height * 4))() + + if not OSMesaMakeCurrent(ctx, buffer, _gl.GL_UNSIGNED_BYTE, width, height): + OSMesaDestroyContext(ctx) + raise RuntimeError("OSMesaMakeCurrent() failed") + + return ctx, buffer + + class GLContext: - """Manages OpenGL context and resources for shader execution.""" + """Manages OpenGL context and resources for shader execution. + + Tries backends in order: GLFW (desktop) → EGL (headless GPU) → OSMesa (software). + """ _instance = None _initialized = False @@ -101,27 +263,85 @@ class GLContext: return GLContext._initialized = True + global glfw, EGL + import time start = time.perf_counter() - if not glfw.init(): - raise RuntimeError("Failed to initialize GLFW") + self._backend = None + self._window = None + self._egl_display = None + self._egl_context = None + self._egl_surface = None + self._osmesa_ctx = None + self._osmesa_buffer = None - glfw.window_hint(glfw.VISIBLE, glfw.FALSE) - glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) - glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) - glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) + # Try backends in order: GLFW → EGL → OSMesa + errors = [] - self._window = glfw.create_window(64, 64, "ComfyUI GLSL", None, None) - if not self._window: - glfw.terminate() - raise RuntimeError("Failed to create GLFW window") + try: + self._window, glfw = _init_glfw() + self._backend = "glfw" + except Exception as e: + errors.append(("GLFW", e)) - glfw.make_context_current(self._window) + if self._backend is None: + try: + self._egl_display, self._egl_context, self._egl_surface, EGL = _init_egl() + self._backend = "egl" + except Exception as e: + errors.append(("EGL", e)) - # Create VAO (required for core profile even if we don't use vertex attributes) - self._vao = gl.glGenVertexArrays(1) - gl.glBindVertexArray(self._vao) + if self._backend is None: + try: + self._osmesa_ctx, self._osmesa_buffer = _init_osmesa() + self._backend = "osmesa" + except Exception as e: + errors.append(("OSMesa", e)) + + if self._backend is None: + if sys.platform == "win32": + platform_help = ( + "Windows: Ensure GPU drivers are installed and display is available.\n" + " CPU-only/headless mode is not supported on Windows." + ) + elif sys.platform == "darwin": + platform_help = ( + "macOS: Ensure display is available. For headless, try virtual display." + ) + else: + platform_help = ( + "Linux: Install one of these backends:\n" + " Desktop: sudo apt install libgl1-mesa-glx libglfw3\n" + " Headless with GPU: sudo apt install libegl1-mesa libgl1-mesa-dri\n" + " Headless (CPU): sudo apt install libosmesa6" + ) + + error_details = "\n".join(f" {name}: {err}" for name, err in errors) + raise RuntimeError( + f"Failed to create OpenGL context.\n\n" + f"Backend errors:\n{error_details}\n\n" + f"{platform_help}\n\n" + "Python packages: pip install PyOpenGL PyOpenGL-accelerate glfw" + ) + + # Now import OpenGL.GL (after context is current) + _import_opengl() + + # Create VAO (required for core profile, but OSMesa may use compat profile) + self._vao = None + try: + vao = gl.glGenVertexArrays(1) + gl.glBindVertexArray(vao) + self._vao = vao # Only store after successful bind + except Exception: + # OSMesa with older Mesa may not support VAOs + # Clean up if we created but couldn't bind + if vao: + try: + gl.glDeleteVertexArrays(1, [vao]) + except Exception: + pass elapsed = (time.perf_counter() - start) * 1000 @@ -133,11 +353,20 @@ class GLContext: vendor = vendor.decode() if vendor else "Unknown" version = version.decode() if version else "Unknown" - logger.info(f"GLSL context initialized in {elapsed:.1f}ms - {renderer} ({vendor}), GL {version}") + logger.info(f"GLSL context initialized in {elapsed:.1f}ms ({self._backend}) - {renderer} ({vendor}), GL {version}") def make_current(self): - glfw.make_context_current(self._window) - gl.glBindVertexArray(self._vao) + if self._backend == "glfw": + glfw.make_context_current(self._window) + elif self._backend == "egl": + from OpenGL.EGL import eglMakeCurrent + eglMakeCurrent(self._egl_display, self._egl_surface, self._egl_surface, self._egl_context) + elif self._backend == "osmesa": + from OpenGL.osmesa import OSMesaMakeCurrent + OSMesaMakeCurrent(self._osmesa_ctx, self._osmesa_buffer, gl.GL_UNSIGNED_BYTE, 64, 64) + + if self._vao is not None: + gl.glBindVertexArray(self._vao) def _compile_shader(source: str, shader_type: int) -> int: @@ -292,14 +521,15 @@ def _render_shader_batch( gl.glBindTexture(gl.GL_TEXTURE_2D, input_textures[i]) # Flip vertically for GL coordinates, ensure RGBA - img_flipped = np.ascontiguousarray(img[::-1, :, :]) - if img_flipped.shape[2] == 3: - img_flipped = np.ascontiguousarray(np.concatenate( - [img_flipped, np.ones((*img_flipped.shape[:2], 1), dtype=np.float32)], - axis=2, - )) + h, w, c = img.shape + if c == 3: + img_upload = np.empty((h, w, 4), dtype=np.float32) + img_upload[:, :, :3] = img[::-1, :, :] + img_upload[:, :, 3] = 1.0 + else: + img_upload = np.ascontiguousarray(img[::-1, :, :]) - gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA32F, img_flipped.shape[1], img_flipped.shape[0], 0, gl.GL_RGBA, gl.GL_FLOAT, img_flipped) + gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA32F, w, h, 0, gl.GL_RGBA, gl.GL_FLOAT, img_upload) # Render gl.glClearColor(0, 0, 0, 0) @@ -307,6 +537,7 @@ def _render_shader_batch( gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3) # Read back outputs for this batch + # (glGetTexImage is synchronous, implicitly waits for rendering) batch_outputs = [] for tex in output_textures: gl.glBindTexture(gl.GL_TEXTURE_2D, tex) From 59b955ff5491001ed84c32eac904cf3332c84ce2 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 29 Jan 2026 20:14:26 -0800 Subject: [PATCH 11/12] fix ci perf: only read required outputs --- comfy_extras/nodes_glsl.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index 170d1d786..f2fc14a3b 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -41,11 +41,12 @@ def _check_opengl_availability(): has_egl = ctypes.util.find_library("EGL") has_osmesa = ctypes.util.find_library("OSMesa") - if not has_egl and not has_osmesa: - raise RuntimeError( - "GLSL Shader node: No display and no headless backend (EGL/OSMesa) found.\n" - "See error below for installation instructions." - ) + # Error disabled for CI as it fails this check + # if not has_egl and not has_osmesa: + # raise RuntimeError( + # "GLSL Shader node: No display and no headless backend (EGL/OSMesa) found.\n" + # "See error below for installation instructions." + # ) logger.debug(f"Headless mode: EGL={'yes' if has_egl else 'no'}, OSMesa={'yes' if has_osmesa else 'no'}") @@ -121,6 +122,18 @@ def _convert_es_to_desktop(source: str) -> str: return "#version 330 core\n" + source +def _detect_output_count(source: str) -> int: + """Detect how many fragColor outputs are used in the shader. + + Returns the count of outputs needed (1 to MAX_OUTPUTS). + """ + matches = re.findall(r"fragColor(\d+)", source) + if not matches: + return 1 # Default to 1 output if none found + max_index = max(int(m) for m in matches) + return min(max_index + 1, MAX_OUTPUTS) + + def _init_glfw(): """Initialize GLFW. Returns (window, glfw_module). Raises RuntimeError on failure.""" import glfw as _glfw @@ -441,6 +454,9 @@ def _render_shader_batch( # Convert from GLSL ES to desktop GLSL 330 fragment_source = _convert_es_to_desktop(fragment_code) + # Detect how many outputs the shader actually uses + num_outputs = _detect_output_count(fragment_code) + # Track resources for cleanup program = None fbo = None @@ -459,12 +475,12 @@ def _render_shader_batch( gl.glUseProgram(program) - # Create framebuffer with multiple color attachments (reused for all batches) + # Create framebuffer with only the needed color attachments fbo = gl.glGenFramebuffers(1) gl.glBindFramebuffer(gl.GL_FRAMEBUFFER, fbo) draw_buffers = [] - for i in range(MAX_OUTPUTS): + for i in range(num_outputs): tex = gl.glGenTextures(1) output_textures.append(tex) gl.glBindTexture(gl.GL_TEXTURE_2D, tex) @@ -474,7 +490,7 @@ def _render_shader_batch( gl.glFramebufferTexture2D(gl.GL_FRAMEBUFFER, gl.GL_COLOR_ATTACHMENT0 + i, gl.GL_TEXTURE_2D, tex, 0) draw_buffers.append(gl.GL_COLOR_ATTACHMENT0 + i) - gl.glDrawBuffers(MAX_OUTPUTS, draw_buffers) + gl.glDrawBuffers(num_outputs, draw_buffers) if gl.glCheckFramebufferStatus(gl.GL_FRAMEBUFFER) != gl.GL_FRAMEBUFFER_COMPLETE: raise RuntimeError("Framebuffer is not complete") @@ -545,6 +561,11 @@ def _render_shader_batch( img = np.frombuffer(data, dtype=np.float32).reshape(height, width, 4) batch_outputs.append(np.ascontiguousarray(img[::-1, :, :])) + # Pad with black images for unused outputs + black_img = np.zeros((height, width, 4), dtype=np.float32) + for _ in range(num_outputs, MAX_OUTPUTS): + batch_outputs.append(black_img) + all_batch_outputs.append(batch_outputs) return all_batch_outputs From c3d07bec6df9f5c6a7c8255502781495fda400cc Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Fri, 30 Jan 2026 12:26:04 -0800 Subject: [PATCH 12/12] add diagnostics, update mac initialization --- comfy_extras/nodes_glsl.py | 66 +++++++++++++++++++++++++++++++++++--- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/comfy_extras/nodes_glsl.py b/comfy_extras/nodes_glsl.py index f2fc14a3b..19c05175e 100644 --- a/comfy_extras/nodes_glsl.py +++ b/comfy_extras/nodes_glsl.py @@ -19,12 +19,15 @@ logger = logging.getLogger(__name__) def _check_opengl_availability(): """Early check for OpenGL availability. Raises RuntimeError if unlikely to work.""" + logger.debug("_check_opengl_availability: starting") missing = [] # Check Python packages (using find_spec to avoid importing) + logger.debug("_check_opengl_availability: checking for glfw package") if importlib.util.find_spec("glfw") is None: missing.append("glfw") + logger.debug("_check_opengl_availability: checking for OpenGL package") if importlib.util.find_spec("OpenGL") is None: missing.append("PyOpenGL") @@ -34,11 +37,15 @@ def _check_opengl_availability(): ) # On Linux without display, check if headless backends are available + logger.debug(f"_check_opengl_availability: platform={sys.platform}") if sys.platform.startswith("linux"): has_display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY") + logger.debug(f"_check_opengl_availability: has_display={bool(has_display)}") if not has_display: # Check for EGL or OSMesa libraries + logger.debug("_check_opengl_availability: checking for EGL library") has_egl = ctypes.util.find_library("EGL") + logger.debug("_check_opengl_availability: checking for OSMesa library") has_osmesa = ctypes.util.find_library("OSMesa") # Error disabled for CI as it fails this check @@ -49,8 +56,11 @@ def _check_opengl_availability(): # ) logger.debug(f"Headless mode: EGL={'yes' if has_egl else 'no'}, OSMesa={'yes' if has_osmesa else 'no'}") + logger.debug("_check_opengl_availability: completed") + # Run early check at import time +logger.debug("nodes_glsl: running _check_opengl_availability at import time") _check_opengl_availability() # OpenGL modules - initialized lazily when context is created @@ -63,8 +73,10 @@ def _import_opengl(): """Import OpenGL module. Called after context is created.""" global gl if gl is None: + logger.debug("_import_opengl: importing OpenGL.GL") import OpenGL.GL as _gl gl = _gl + logger.debug("_import_opengl: import completed") return gl @@ -136,30 +148,44 @@ def _detect_output_count(source: str) -> int: def _init_glfw(): """Initialize GLFW. Returns (window, glfw_module). Raises RuntimeError on failure.""" + logger.debug("_init_glfw: starting") + # On macOS, glfw.init() must be called from main thread or it hangs forever + if sys.platform == "darwin": + logger.debug("_init_glfw: skipping on macOS") + raise RuntimeError("GLFW backend not supported on macOS") + + logger.debug("_init_glfw: importing glfw module") import glfw as _glfw + logger.debug("_init_glfw: calling glfw.init()") if not _glfw.init(): raise RuntimeError("glfw.init() failed") try: + logger.debug("_init_glfw: setting window hints") _glfw.window_hint(_glfw.VISIBLE, _glfw.FALSE) _glfw.window_hint(_glfw.CONTEXT_VERSION_MAJOR, 3) _glfw.window_hint(_glfw.CONTEXT_VERSION_MINOR, 3) _glfw.window_hint(_glfw.OPENGL_PROFILE, _glfw.OPENGL_CORE_PROFILE) + logger.debug("_init_glfw: calling create_window()") window = _glfw.create_window(64, 64, "ComfyUI GLSL", None, None) if not window: raise RuntimeError("glfw.create_window() failed") + logger.debug("_init_glfw: calling make_context_current()") _glfw.make_context_current(window) + logger.debug("_init_glfw: completed successfully") return window, _glfw except Exception: + logger.debug("_init_glfw: failed, terminating glfw") _glfw.terminate() raise def _init_egl(): """Initialize EGL for headless rendering. Returns (display, context, surface, EGL_module). Raises RuntimeError on failure.""" + logger.debug("_init_egl: starting") from OpenGL import EGL as _EGL from OpenGL.EGL import ( eglGetDisplay, eglInitialize, eglChooseConfig, eglCreateContext, @@ -170,20 +196,24 @@ def _init_egl(): EGL_RED_SIZE, EGL_GREEN_SIZE, EGL_BLUE_SIZE, EGL_ALPHA_SIZE, EGL_DEPTH_SIZE, EGL_WIDTH, EGL_HEIGHT, EGL_OPENGL_API, ) + logger.debug("_init_egl: imports completed") display = None context = None surface = None try: + logger.debug("_init_egl: calling eglGetDisplay()") display = eglGetDisplay(EGL_DEFAULT_DISPLAY) if display == _EGL.EGL_NO_DISPLAY: raise RuntimeError("eglGetDisplay() failed") + logger.debug("_init_egl: calling eglInitialize()") major, minor = _EGL.EGLint(), _EGL.EGLint() if not eglInitialize(display, major, minor): display = None # Not initialized, don't terminate raise RuntimeError("eglInitialize() failed") + logger.debug(f"_init_egl: EGL version {major.value}.{minor.value}") config_attribs = [ EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, @@ -196,10 +226,12 @@ def _init_egl(): if not eglChooseConfig(display, config_attribs, configs, 1, num_configs) or num_configs.value == 0: raise RuntimeError("eglChooseConfig() failed") config = configs[0] + logger.debug(f"_init_egl: config chosen, num_configs={num_configs.value}") if not eglBindAPI(EGL_OPENGL_API): raise RuntimeError("eglBindAPI() failed") + logger.debug("_init_egl: calling eglCreateContext()") context_attribs = [ _EGL.EGL_CONTEXT_MAJOR_VERSION, 3, _EGL.EGL_CONTEXT_MINOR_VERSION, 3, @@ -210,17 +242,21 @@ def _init_egl(): if context == EGL_NO_CONTEXT: raise RuntimeError("eglCreateContext() failed") + logger.debug("_init_egl: calling eglCreatePbufferSurface()") pbuffer_attribs = [EGL_WIDTH, 64, EGL_HEIGHT, 64, EGL_NONE] surface = eglCreatePbufferSurface(display, config, pbuffer_attribs) if surface == _EGL.EGL_NO_SURFACE: raise RuntimeError("eglCreatePbufferSurface() failed") + logger.debug("_init_egl: calling eglMakeCurrent()") if not eglMakeCurrent(display, surface, surface, context): raise RuntimeError("eglMakeCurrent() failed") + logger.debug("_init_egl: completed successfully") return display, context, surface, _EGL except Exception: + logger.debug("_init_egl: failed, cleaning up") # Clean up any resources on failure if surface is not None: eglDestroySurface(display, surface) @@ -235,13 +271,16 @@ def _init_osmesa(): """Initialize OSMesa for software rendering. Returns (context, buffer). Raises RuntimeError on failure.""" import ctypes + logger.debug("_init_osmesa: starting") os.environ["PYOPENGL_PLATFORM"] = "osmesa" + logger.debug("_init_osmesa: importing OpenGL.osmesa") from OpenGL import GL as _gl from OpenGL.osmesa import ( OSMesaCreateContextExt, OSMesaMakeCurrent, OSMesaDestroyContext, OSMESA_RGBA, ) + logger.debug("_init_osmesa: imports completed") ctx = OSMesaCreateContextExt(OSMESA_RGBA, 24, 0, 0, None) if not ctx: @@ -250,10 +289,12 @@ def _init_osmesa(): width, height = 64, 64 buffer = (ctypes.c_ubyte * (width * height * 4))() + logger.debug("_init_osmesa: calling OSMesaMakeCurrent()") if not OSMesaMakeCurrent(ctx, buffer, _gl.GL_UNSIGNED_BYTE, width, height): OSMesaDestroyContext(ctx) raise RuntimeError("OSMesaMakeCurrent() failed") + logger.debug("_init_osmesa: completed successfully") return ctx, buffer @@ -273,9 +314,12 @@ class GLContext: def __init__(self): if GLContext._initialized: + logger.debug("GLContext.__init__: already initialized, skipping") return GLContext._initialized = True + logger.debug("GLContext.__init__: starting initialization") + global glfw, EGL import time @@ -292,24 +336,33 @@ class GLContext: # Try backends in order: GLFW → EGL → OSMesa errors = [] + logger.debug("GLContext.__init__: trying GLFW backend") try: self._window, glfw = _init_glfw() self._backend = "glfw" + logger.debug("GLContext.__init__: GLFW backend succeeded") except Exception as e: + logger.debug(f"GLContext.__init__: GLFW backend failed: {e}") errors.append(("GLFW", e)) if self._backend is None: + logger.debug("GLContext.__init__: trying EGL backend") try: self._egl_display, self._egl_context, self._egl_surface, EGL = _init_egl() self._backend = "egl" + logger.debug("GLContext.__init__: EGL backend succeeded") except Exception as e: + logger.debug(f"GLContext.__init__: EGL backend failed: {e}") errors.append(("EGL", e)) if self._backend is None: + logger.debug("GLContext.__init__: trying OSMesa backend") try: self._osmesa_ctx, self._osmesa_buffer = _init_osmesa() self._backend = "osmesa" + logger.debug("GLContext.__init__: OSMesa backend succeeded") except Exception as e: + logger.debug(f"GLContext.__init__: OSMesa backend failed: {e}") errors.append(("OSMesa", e)) if self._backend is None: @@ -320,7 +373,9 @@ class GLContext: ) elif sys.platform == "darwin": platform_help = ( - "macOS: Ensure display is available. For headless, try virtual display." + "macOS: GLFW is not supported.\n" + " Install OSMesa via Homebrew: brew install mesa\n" + " Then: pip install PyOpenGL PyOpenGL-accelerate" ) else: platform_help = ( @@ -334,20 +389,23 @@ class GLContext: raise RuntimeError( f"Failed to create OpenGL context.\n\n" f"Backend errors:\n{error_details}\n\n" - f"{platform_help}\n\n" - "Python packages: pip install PyOpenGL PyOpenGL-accelerate glfw" + f"{platform_help}" ) # Now import OpenGL.GL (after context is current) + logger.debug("GLContext.__init__: importing OpenGL.GL") _import_opengl() # Create VAO (required for core profile, but OSMesa may use compat profile) + logger.debug("GLContext.__init__: creating VAO") self._vao = None try: vao = gl.glGenVertexArrays(1) gl.glBindVertexArray(vao) self._vao = vao # Only store after successful bind - except Exception: + logger.debug("GLContext.__init__: VAO created successfully") + except Exception as e: + logger.debug(f"GLContext.__init__: VAO creation failed (may be expected for OSMesa): {e}") # OSMesa with older Mesa may not support VAOs # Clean up if we created but couldn't bind if vao: