From 0de9b43d7a0b36de5bc399195d3eb8fc83d8b09e Mon Sep 17 00:00:00 2001 From: Crespin Alexandre <114501859+kunail0804@users.noreply.github.com> Date: Wed, 8 Jul 2026 14:08:03 +0200 Subject: [PATCH] feat(nodes): add the Scene Analyzer ComfyUI node (#19) (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the Scene Analyzer as a ComfyUI node (MASTER_SPEC §20) plus the node registration plumbing and a shared output adapter. - nodes/scene_analyzer_node.py: SceneAnalyzerNode, a thin adapter. Inputs are Natural Language, Model Name, Temperature, Timeout, and an optional System Prompt override; it builds a Config, constructs OllamaBackend.from_config, and delegates to analyze(). Outputs (scene, warnings, errors): the SCENE model and the warnings/errors formatted for display. No compiler logic in the node. - nodes/adapters.py: format_messages() renders diagnostics as CODE: description lines. - Registration: nodes/__init__.py aggregates NODE_CLASS_MAPPINGS / NODE_DISPLAY_NAME_MAPPINGS; the root __init__.py re-exports them. ComfyUI compatibility: the node package uses relative imports internally so our `nodes` package is never resolved as ComfyUI's own top-level `nodes` module, and the entry point puts the repo root on sys.path so `compiler`/`schemas` resolve when ComfyUI loads the package by file path. Tests (no live Ollama): INPUT_TYPES/RETURN metadata, delegation to analyze (monkeypatched) with warnings/errors surfaced, empty-message strings, system prompt override passthrough, and ComfyUI registration. Co-authored-by: kunail0804 Co-authored-by: Claude Opus 4.8 --- __init__.py | 26 ++++++++--- nodes/__init__.py | 21 ++++++++- nodes/adapters.py | 19 ++++++++ nodes/scene_analyzer_node.py | 60 ++++++++++++++++++++++++ tests/test_scene_analyzer_node.py | 77 +++++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 8 deletions(-) create mode 100644 nodes/adapters.py create mode 100644 nodes/scene_analyzer_node.py create mode 100644 tests/test_scene_analyzer_node.py diff --git a/__init__.py b/__init__.py index 75c35de..d691b9d 100644 --- a/__init__.py +++ b/__init__.py @@ -2,15 +2,27 @@ ComfyUI loads this module by path and reads ``NODE_CLASS_MAPPINGS`` / ``NODE_DISPLAY_NAME_MAPPINGS`` to register the compiler's nodes. The mappings are -populated in Phase 5 when the nodes are implemented; until then they are empty so -that a fresh install imports cleanly without registering anything. +aggregated in the ``nodes`` package; the compiler logic lives entirely in the +``compiler`` package, which never imports ComfyUI. -Compiler logic never lives here — it belongs in the ``compiler/`` package. This -file is an interface for ComfyUI only. +The ``compiler`` and ``schemas`` packages are imported by their top-level names, +so this entry point puts the repository root on ``sys.path`` when ComfyUI loads it +by file path. The ``nodes`` package is only ever imported relative to this +package (never as a top-level ``nodes``), so it does not collide with ComfyUI's +own core ``nodes`` module. """ -# Populated by nodes/ in Phase 5 (issues #19–#26). -NODE_CLASS_MAPPINGS: dict[str, type] = {} -NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {} +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +try: + # ComfyUI loads this file inside a package, so the relative import resolves. + from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +except ImportError: + # Imported standalone (e.g. by the test runner): fall back to the top-level + # package that is on sys.path. + from nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/__init__.py b/nodes/__init__.py index c3743dc..57fcaea 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -1 +1,20 @@ -"""nodes package.""" +"""ComfyUI node registrations for Scene Compiler. + +Each node lives in its own module and is aggregated here into the +``NODE_CLASS_MAPPINGS`` / ``NODE_DISPLAY_NAME_MAPPINGS`` that ComfyUI reads. +Nodes are thin interfaces; all compiler logic lives in the ``compiler`` package. +""" + +from __future__ import annotations + +from .scene_analyzer_node import SceneAnalyzerNode + +NODE_CLASS_MAPPINGS: dict[str, type] = { + "SceneCompilerAnalyzer": SceneAnalyzerNode, +} + +NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = { + "SceneCompilerAnalyzer": "Scene Analyzer", +} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/nodes/adapters.py b/nodes/adapters.py new file mode 100644 index 0000000..1f470b6 --- /dev/null +++ b/nodes/adapters.py @@ -0,0 +1,19 @@ +"""Shared helpers for adapting compiler results to ComfyUI node outputs. + +Nodes are thin interfaces; these helpers only format data for display and never +contain compiler logic. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +from compiler.common.result import Message + + +def format_messages(messages: Iterable[Message]) -> str: + """Render diagnostic messages as one ``CODE: description`` line each. + + Returns an empty string when there are no messages. + """ + return "\n".join(f"{message.code}: {message.description}" for message in messages) diff --git a/nodes/scene_analyzer_node.py b/nodes/scene_analyzer_node.py new file mode 100644 index 0000000..b568234 --- /dev/null +++ b/nodes/scene_analyzer_node.py @@ -0,0 +1,60 @@ +"""ComfyUI node for the Scene Analyzer (MASTER_SPEC §20). + +A thin interface: it adapts ComfyUI inputs to the Analyzer module and surfaces the +CompilerResult's data, warnings, and errors. It contains no compiler logic and +never imports ComfyUI. +""" + +from __future__ import annotations + +from typing import Any + +from compiler.analyzer.backend import OllamaBackend +from compiler.analyzer.scene_analyzer import analyze +from compiler.common.config import Config + +from .adapters import format_messages + + +class SceneAnalyzerNode: + """Runs the Scene Analyzer and emits Scene plus warnings/errors.""" + + CATEGORY = "Scene Compiler" + FUNCTION = "run" + RETURN_TYPES = ("SCENE", "STRING", "STRING") + RETURN_NAMES = ("scene", "warnings", "errors") + + @classmethod + def INPUT_TYPES(cls) -> dict[str, Any]: + return { + "required": { + "natural_language": ("STRING", {"multiline": True, "default": ""}), + "model_name": ("STRING", {"default": "llama3"}), + "temperature": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.1}), + "timeout": ("INT", {"default": 60, "min": 1, "max": 3600}), + }, + "optional": { + "system_prompt": ("STRING", {"multiline": True, "default": ""}), + }, + } + + def run( + self, + natural_language: str, + model_name: str, + temperature: float, + timeout: int, + system_prompt: str = "", + ) -> tuple[Any, str, str]: + analyzer: dict[str, Any] = { + "model": model_name, + "temperature": temperature, + "timeout": timeout, + } + if system_prompt: + analyzer["system_prompt"] = system_prompt + + config = Config.from_json({"analyzer": analyzer}) + backend = OllamaBackend.from_config(config) + result = analyze(natural_language, backend, config) + return (result.data, format_messages(result.warnings), format_messages(result.errors)) diff --git a/tests/test_scene_analyzer_node.py b/tests/test_scene_analyzer_node.py new file mode 100644 index 0000000..063224d --- /dev/null +++ b/tests/test_scene_analyzer_node.py @@ -0,0 +1,77 @@ +"""Tests for the Scene Analyzer ComfyUI node (issue #19). No live Ollama.""" + +from __future__ import annotations + +import nodes +import nodes.scene_analyzer_node as node_module +from compiler.common.message_codes import message +from compiler.common.result import CompilerResult +from nodes.scene_analyzer_node import SceneAnalyzerNode + + +def test_input_types_structure() -> None: + inputs = SceneAnalyzerNode.INPUT_TYPES() + assert set(inputs["required"]) == { + "natural_language", + "model_name", + "temperature", + "timeout", + } + assert "system_prompt" in inputs["optional"] + + +def test_node_metadata() -> None: + assert SceneAnalyzerNode.RETURN_TYPES == ("SCENE", "STRING", "STRING") + assert SceneAnalyzerNode.RETURN_NAMES == ("scene", "warnings", "errors") + assert SceneAnalyzerNode.FUNCTION == "run" + assert SceneAnalyzerNode.CATEGORY == "Scene Compiler" + + +def test_run_delegates_to_analyzer_and_surfaces_messages(monkeypatch) -> None: + captured = {} + + def fake_analyze(description, backend, config): + captured["description"] = description + captured["model"] = config.analyzer.model + return ( + CompilerResult(data="SCENE_MODEL") + .add_warning(message("SC0001", "unknown concept 'x'")) + .add_error(message("SC0011", "bad response")) + ) + + monkeypatch.setattr(node_module, "analyze", fake_analyze) + + scene, warnings, errors = SceneAnalyzerNode().run( + natural_language="A girl.", + model_name="mistral", + temperature=0.0, + timeout=60, + ) + assert scene == "SCENE_MODEL" + assert captured["description"] == "A girl." + assert captured["model"] == "mistral" + assert "SC0001: unknown concept 'x'" in warnings + assert "SC0011: bad response" in errors + + +def test_no_messages_yield_empty_strings(monkeypatch) -> None: + monkeypatch.setattr(node_module, "analyze", lambda d, b, c: CompilerResult(data="S")) + scene, warnings, errors = SceneAnalyzerNode().run("x", "llama3", 0.0, 60) + assert (warnings, errors) == ("", "") + + +def test_system_prompt_override_is_passed(monkeypatch) -> None: + captured = {} + + def fake_analyze(description, backend, config): + captured["system_prompt"] = config.analyzer.system_prompt + return CompilerResult(data="S") + + monkeypatch.setattr(node_module, "analyze", fake_analyze) + SceneAnalyzerNode().run("x", "llama3", 0.0, 60, system_prompt="CUSTOM") + assert captured["system_prompt"] == "CUSTOM" + + +def test_registered_for_comfyui() -> None: + assert nodes.NODE_CLASS_MAPPINGS["SceneCompilerAnalyzer"] is SceneAnalyzerNode + assert nodes.NODE_DISPLAY_NAME_MAPPINGS["SceneCompilerAnalyzer"] == "Scene Analyzer"