diff --git a/__init__.py b/__init__.py index abd8297..53d84c1 100644 --- a/__init__.py +++ b/__init__.py @@ -1,7 +1,7 @@ """ -Node Runner - Import & export shader nodes as shareable strings. +Node Runner - Import & export shader, geometry, and compositor nodes as shareable strings. -Serializes Blender shader node trees to compressed, base64-encoded +Serializes Blender shader, geometry, and compositor node trees to compressed, base64-encoded strings that can be shared via text, comments, or documentation. """ @@ -9,8 +9,8 @@ "name": "Node Runner", "description": "Import and export nodes as strings", "author": "Noah Thiering ", - "version": (1, 4, 1), - "blender": (4, 5, 0), + "version": (1, 4, 7), + "blender": (4, 2, 0), "category": "Node", } diff --git a/blender_manifest.toml b/blender_manifest.toml index 85b75d5..30c296f 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,16 +1,16 @@ schema_version = "1.0.0" id = "node_runner" -version = "1.4.1" +version = "1.4.2" name = "Node Runner" -tagline = "Import and export nodes as strings" +tagline = "Import and export shader, geometry, and compositor nodes" maintainer = "Noah Thiering " type = "add-on" tags = ["Node", "Material", "Geometry Nodes"] -blender_version_min = "4.5.0" +blender_version_min = "4.2.0" # https://docs.blender.org/manual/en/dev/advanced/extensions/licenses.html license = ["SPDX:GPL-3.0-or-later"] diff --git a/deserialize.py b/deserialize.py index 862bbf2..3e3ef6e 100644 --- a/deserialize.py +++ b/deserialize.py @@ -18,21 +18,81 @@ log = logging.getLogger(__name__) +def _node_absolute_location(node): + """Return node location in absolute canvas coordinates. + + Blender 4.5 has ``Node.location_absolute``. Blender 4.2 may not expose + it on every node, so reconstruct it from ``location`` and parent frames. + """ + loc_abs = getattr(node, "location_absolute", None) + if loc_abs is not None: + return [float(loc_abs[0]), float(loc_abs[1])] + + loc = getattr(node, "location", (0.0, 0.0)) + x = float(loc[0]) if len(loc) > 0 else 0.0 + y = float(loc[1]) if len(loc) > 1 else 0.0 + + parent = getattr(node, "parent", None) + if parent is not None: + parent_abs = _node_absolute_location(parent) + x += float(parent_abs[0]) + y += float(parent_abs[1]) + + return [x, y] + # Helpers +def _rna_type_exists(type_name: str) -> bool: + """Return True when *type_name* exists in this Blender build.""" + return bool(type_name and getattr(bpy.types, type_name, None) is not None) + + def get_node_socket_base_type(socket_type: str) -> str: - """Map a specific socket type string to its base type. + """Map a specific socket type string to a base type supported here. - Only base types can be used with ``NodeTreeInterface.new_socket()``. - Returns ``'NodeSocketFloat'`` as last-resort fallback. + Blender 4.5 can export sockets/nodes that older Blender 4.x builds do + not know. Blender 4.2 should not crash just because a newer socket type + appears in a payload, so unsupported types gracefully fall back to the + closest broad socket family and finally to ``NodeSocketFloat``. """ + socket_type = socket_type or "" + for base in SOCKET_BASE_TYPES: - if base in socket_type: + if base in socket_type and _rna_type_exists(base): return base + + family_fallbacks = ( + ("Bool", "NodeSocketBool"), + ("Boolean", "NodeSocketBool"), + ("Vector", "NodeSocketVector"), + ("Rotation", "NodeSocketRotation"), + ("Matrix", "NodeSocketVector"), + ("Int", "NodeSocketInt"), + ("Integer", "NodeSocketInt"), + ("Color", "NodeSocketColor"), + ("Shader", "NodeSocketShader"), + ("String", "NodeSocketString"), + ("Image", "NodeSocketImage"), + ("Object", "NodeSocketObject"), + ("Collection", "NodeSocketCollection"), + ("Geometry", "NodeSocketGeometry"), + ("Menu", "NodeSocketMenu"), + ("Material", "NodeSocketMaterial"), + ("Texture", "NodeSocketTexture"), + ) + for token, fallback in family_fallbacks: + if token in socket_type and _rna_type_exists(fallback): + return fallback + return "NodeSocketFloat" + +def _interface_socket_map_key(in_out, identifier): + return f"interface:{in_out}:{identifier}" + + def get_socket_by_identifier( node, identifier, socket_id_map, direction="INPUT", name=None ): @@ -55,8 +115,16 @@ def get_socket_by_identifier( sockets = node.inputs if direction == "INPUT" else node.outputs resolved_id = identifier - if node.bl_idname in ("NodeGroupOutput", "NodeGroupInput"): - resolved_id = socket_id_map.get(identifier, identifier) + if node.bl_idname == "NodeGroupInput": + resolved_id = socket_id_map.get( + _interface_socket_map_key("INPUT", identifier), + socket_id_map.get(identifier, identifier), + ) + elif node.bl_idname == "NodeGroupOutput": + resolved_id = socket_id_map.get( + _interface_socket_map_key("OUTPUT", identifier), + socket_id_map.get(identifier, identifier), + ) elif node.bl_idname == "ShaderNodeGroup" and hasattr(node, "node_tree"): child_key = "child_" + node.node_tree.name if child_key in socket_id_map: @@ -96,12 +164,257 @@ def create_interface_socket(node_tree, name, description, in_out, socket_type): Returns: The newly created ``NodeTreeInterfaceSocket``. """ - return node_tree.interface.new_socket( - name=name, - description=description, - in_out=in_out, - socket_type=socket_type, - ) + socket_type = get_node_socket_base_type(socket_type) + try: + return node_tree.interface.new_socket( + name=name, + description=description, + in_out=in_out, + socket_type=socket_type, + ) + except (TypeError, RuntimeError, ValueError) as exc: + # Last-resort fallback for interface socket types unsupported by + # Blender 4.2. This keeps the import usable instead of aborting the + # whole node tree. The link/default may not be exact for the skipped + # type, but the surrounding graph survives. + log.warning( + "Could not create interface socket '%s' as %s: %s; using Float", + name, + socket_type, + exc, + ) + return node_tree.interface.new_socket( + name=name, + description=description, + in_out=in_out, + socket_type="NodeSocketFloat", + ) + + +def _apply_group_interface_socket_policy(data): + """Reduce Group Input/Output interface data for selected-node imports. + + Node Runner exports selected nodes, not a guaranteed complete node-tree + snapshot. In Blender, every Group Input node serializes the whole group + interface, even if the selected node island only uses a few of those + sockets. Older payloads therefore recreated many unrelated empty inputs + on import. Unless a payload explicitly asks for a full interface, keep + only sockets referenced by exported links. + """ + nodes = data.get("nodes", {}) + if not nodes: + return + + policy = data.get("interface_socket_policy") + # Future-proof escape hatch: a payload may explicitly request the whole + # interface. Current Node Runner selected-node exports use linked_only. + if policy == "full": + return + + used_group_input_outputs = {} + used_group_output_inputs = {} + + for link in data.get("links", []): + from_name = link.get("from_node") + to_name = link.get("to_node") + from_node = nodes.get(from_name, {}) + to_node = nodes.get(to_name, {}) + + if from_node.get("type") == "NodeGroupInput": + ident = link.get("from_socket_identifier") + if ident: + used_group_input_outputs.setdefault(from_name, set()).add(ident) + + if to_node.get("type") == "NodeGroupOutput": + ident = link.get("to_socket_identifier") + if ident: + used_group_output_inputs.setdefault(to_name, set()).add(ident) + + # For both new linked_only payloads and legacy 1.4.3 payloads without a + # policy field, prune to the sockets that links actually need. + for node_name, node_data in nodes.items(): + if node_data.get("type") == "NodeGroupInput" and "output_order" in node_data: + used = used_group_input_outputs.get(node_name, set()) + node_data["output_order"] = [ + item + for item in node_data.get("output_order", []) + if item.get("identifier") in used + ] + elif node_data.get("type") == "NodeGroupOutput" and "input_order" in node_data: + used = used_group_output_inputs.get(node_name, set()) + node_data["input_order"] = [ + item + for item in node_data.get("input_order", []) + if item.get("identifier") in used + ] + + +def _socket_sort_key_from_original_order(nodes, socket_entries): + """Return a stable key function using the first full-ish Group Input order found.""" + order = {} + idx = 0 + for node_data in nodes.values(): + if node_data.get("type") != "NodeGroupInput": + continue + for item in node_data.get("output_order", []): + ident = item.get("identifier") + if ident and ident not in order: + order[ident] = idx + idx += 1 + return lambda item: order.get(item.get("identifier"), 10_000) + + +def _compact_group_input_nodes(data): + """Collapse multiple Group Input nodes into one compact node for snippets. + + Blender does not support a different visible interface per Group Input node: + every Group Input instance displays the same node-tree interface. If a + selected-node snippet contains several Group Input nodes, creating the union + of needed interface sockets makes each instance look like it has many + unrelated sockets. For linked-only snippets, keep one Group Input node and + redirect all outgoing Group Input links to it. + """ + if data.get("interface_socket_policy") == "full": + return + + nodes = data.get("nodes", {}) + links = data.get("links", []) + group_input_names = [ + name for name, node_data in nodes.items() + if node_data.get("type") == "NodeGroupInput" + ] + if len(group_input_names) <= 1: + return + + linked_from = { + link.get("from_node") + for link in links + if nodes.get(link.get("from_node"), {}).get("type") == "NodeGroupInput" + } + if not linked_from: + # Nothing in the pasted snippet actually uses these nodes. Remove them. + for name in group_input_names: + nodes.pop(name, None) + return + + def used_count(name): + return sum(1 for link in links if link.get("from_node") == name) + + # Prefer the linked Group Input that contributes the most sockets. + # Tie-break by the leftmost position so the resulting node usually stays + # close to the source side of the node island. + def loc_x(name): + loc = nodes.get(name, {}).get("location_absolute") or nodes.get(name, {}).get("location") or [0, 0] + try: + return float(loc[0]) + except (TypeError, ValueError, IndexError): + return 0.0 + + primary = sorted(linked_from, key=lambda n: (-used_count(n), loc_x(n), n))[0] + primary_data = nodes[primary] + + # Build the union of only sockets that are required by actual links. + used_identifiers = { + link.get("from_socket_identifier") + for link in links + if nodes.get(link.get("from_node"), {}).get("type") == "NodeGroupInput" + and link.get("from_socket_identifier") + } + by_identifier = {} + for name in group_input_names: + for item in nodes.get(name, {}).get("output_order", []): + ident = item.get("identifier") + if ident in used_identifiers and ident not in by_identifier: + by_identifier[ident] = item + + # Some very old payloads may not have output_order after pruning. Rebuild + # minimal entries from the link metadata so links can still resolve. + for link in links: + if nodes.get(link.get("from_node"), {}).get("type") != "NodeGroupInput": + continue + ident = link.get("from_socket_identifier") + if ident and ident not in by_identifier: + by_identifier[ident] = { + "type": link.get("from_socket_type", "NodeSocketFloat"), + "name": link.get("from_socket", ""), + "identifier": ident, + } + + sort_key = _socket_sort_key_from_original_order(nodes, by_identifier.values()) + primary_data["output_order"] = sorted(by_identifier.values(), key=sort_key) + + # Redirect all Group Input links to the single remaining node. The socket + # identifier stays the same, so get_socket_by_identifier can still resolve it + # through socket_id_map after the interface sockets are created. + for link in links: + if nodes.get(link.get("from_node"), {}).get("type") == "NodeGroupInput": + link["from_node"] = primary + + for name in group_input_names: + if name != primary: + nodes.pop(name, None) + + data["group_input_policy"] = "single_compact" + + +def _set_interface_socket_default(iface_socket, entry): + if "default" not in entry or not hasattr(iface_socket, "default_value"): + return + try: + iface_socket.default_value = entry["default"] + except (TypeError, AttributeError, ValueError): + log.debug("Could not set default for interface socket '%s'", entry.get("name")) + + +def _hide_group_io_sockets_per_payload_node(node_map, nodes_data, socket_id_map): + """Restore the original per-node compact Group Input/Output display. + + Blender stores the node-group interface globally, so every Group Input node + receives every group input socket. But the visible/hidden state of each + socket on each node instance is local to that node. Selected-node snippets + use ``output_order`` / ``input_order`` as the list of sockets that should be + visible on that particular Group Input/Output node. Hide the rest so the + imported layout matches the source layout instead of producing one very long + Group Input node. + """ + + def mapped_identifiers(entries, in_out): + identifiers = set() + for entry in entries or []: + old_identifier = entry.get("identifier") + if not old_identifier: + continue + map_key = _interface_socket_map_key(in_out, old_identifier) + identifiers.add(socket_id_map.get(map_key, socket_id_map.get(old_identifier, old_identifier))) + return identifiers + + for old_name, node in node_map.items(): + node_data = nodes_data.get(old_name, {}) + node_type = node_data.get("type") + + if node_type == "NodeGroupInput" and hasattr(node, "outputs"): + if "output_order" not in node_data: + continue + visible = mapped_identifiers(node_data.get("output_order", []), "INPUT") + for sock in node.outputs: + if getattr(sock, "bl_idname", "") == "NodeSocketVirtual": + continue + try: + sock.hide = sock.identifier not in visible + except (TypeError, AttributeError, ValueError): + pass + + elif node_type == "NodeGroupOutput" and hasattr(node, "inputs"): + if "input_order" not in node_data: + continue + visible = mapped_identifiers(node_data.get("input_order", []), "OUTPUT") + for sock in node.inputs: + if getattr(sock, "bl_idname", "") == "NodeSocketVirtual": + continue + try: + sock.hide = sock.identifier not in visible + except (TypeError, AttributeError, ValueError): + pass # Type-specific deserializers @@ -232,6 +545,67 @@ def deserialize_image(node, data): log.info("Image '%s' not found in blend file.", img_name) +def resolve_id_reference(payload): + """Resolve a serialized Blender ID pointer by type and name.""" + if not isinstance(payload, dict) or "__id__" not in payload: + return payload + data_map = { + "Scene": bpy.data.scenes, + "MovieClip": bpy.data.movieclips, + "Mask": bpy.data.masks, + "Object": bpy.data.objects, + "Collection": bpy.data.collections, + "Material": bpy.data.materials, + "Image": bpy.data.images, + "Texture": bpy.data.textures, + "World": bpy.data.worlds, + } + data_block = data_map.get(payload.get("__id__")) + if data_block is None: + return None + return data_block.get(payload.get("name", "")) + + +def deserialize_output_file_slots(node, data): + """Restore compositor File Output node slot layout defensively.""" + if not isinstance(data, dict): + return + for collection_name in ("file_slots", "layer_slots"): + slots_data = data.get(collection_name) + slots = getattr(node, collection_name, None) + if slots is None or not isinstance(slots_data, list): + continue + + # Match slot count. Blender's File Output node usually starts with one + # default slot; add/remove only when the API allows it. + try: + while len(slots) < len(slots_data): + label = slots_data[len(slots)].get("name") or slots_data[len(slots)].get("path") or "Image" + slots.new(label) + except (TypeError, AttributeError, RuntimeError): + pass + try: + while len(slots) > len(slots_data) and len(slots) > 0: + slots.remove(slots[-1]) + except (TypeError, AttributeError, RuntimeError): + pass + + try: + pairs = zip(list(slots), slots_data) + except (TypeError, RuntimeError): + continue + for slot, entry in pairs: + if not isinstance(entry, dict): + continue + for attr_name in ("path", "use_node_format", "save_as_render", "name"): + if attr_name not in entry: + continue + try: + setattr(slot, attr_name, entry[attr_name]) + except (TypeError, AttributeError, RuntimeError): + pass + + def deserialize_text_line(text_line, data): """Apply text line data.""" text_line.body = data.get("body", "") @@ -270,22 +644,21 @@ def deserialize_inputs(node, data, node_data, node_tree, socket_id_map): if isinstance(node, (bpy.types.NodeGroupInput, bpy.types.NodeGroupOutput)): if isinstance(node, bpy.types.NodeGroupInput): for inp in node_data.get("output_order", []): + old_identifier = inp.get("identifier") + map_key = _interface_socket_map_key("INPUT", old_identifier) + if old_identifier and map_key in socket_id_map: + continue iface_socket = create_interface_socket( node_tree, - inp["name"], - inp["name"] + " Input", + inp.get("name", ""), + inp.get("name", "") + " Input", "INPUT", - get_node_socket_base_type(inp["type"]), + get_node_socket_base_type(inp.get("type", "NodeSocketFloat")), ) - socket_id_map[inp["identifier"]] = iface_socket.identifier - if "default" in inp and hasattr(iface_socket, "default_value"): - try: - iface_socket.default_value = inp["default"] - except (TypeError, AttributeError, ValueError): - log.debug( - "Could not set default for interface input '%s'", - inp.get("name"), - ) + if old_identifier: + socket_id_map[map_key] = iface_socket.identifier + socket_id_map.setdefault(old_identifier, iface_socket.identifier) + _set_interface_socket_default(iface_socket, inp) return if not hasattr(node, "inputs"): @@ -312,22 +685,21 @@ def deserialize_outputs(node, data, node_data, node_tree, socket_id_map): if isinstance(node, (bpy.types.NodeGroupInput, bpy.types.NodeGroupOutput)): if isinstance(node, bpy.types.NodeGroupOutput): for out in node_data.get("input_order", []): + old_identifier = out.get("identifier") + map_key = _interface_socket_map_key("OUTPUT", old_identifier) + if old_identifier and map_key in socket_id_map: + continue iface_socket = create_interface_socket( node_tree, - out["name"], - out["name"] + " Output", + out.get("name", ""), + out.get("name", "") + " Output", "OUTPUT", - get_node_socket_base_type(out["type"]), + get_node_socket_base_type(out.get("type", "NodeSocketFloat")), ) - socket_id_map[out["identifier"]] = iface_socket.identifier - if "default" in out and hasattr(iface_socket, "default_value"): - try: - iface_socket.default_value = out["default"] - except (TypeError, AttributeError, ValueError): - log.debug( - "Could not set default for interface output '%s'", - out.get("name"), - ) + if old_identifier: + socket_id_map[map_key] = iface_socket.identifier + socket_id_map.setdefault(old_identifier, iface_socket.identifier) + _set_interface_socket_default(iface_socket, out) return if not hasattr(node, "outputs"): @@ -366,17 +738,34 @@ def deserialize_node(node_data, node_tree, socket_id_map, defer_io=False): if node_type == "NodeUndefined": return (None, {}) if defer_io else None - new_node = node_tree.nodes.new(type=node_type) + try: + new_node = node_tree.nodes.new(type=node_type) + except (RuntimeError, TypeError, ValueError) as exc: + log.warning( + "Skipping unsupported node type '%s' in this Blender version: %s", + node_type, + exc, + ) + return (None, {}) if defer_io else None new_node.label = node_data.get("label", "") # Node tree must be created before inputs/outputs if "node_tree" in node_data: nt_data = node_data["node_tree"] tree_type = nt_data.get("tree_type", "ShaderNodeTree") - new_node.node_tree = bpy.data.node_groups.new(nt_data["name"], tree_type) - child_key = "child_" + new_node.node_tree.name - socket_id_map[child_key] = {} - deserialize_node_tree(new_node.node_tree, nt_data, socket_id_map[child_key]) + try: + new_node.node_tree = bpy.data.node_groups.new(nt_data["name"], tree_type) + except (RuntimeError, TypeError, ValueError) as exc: + log.warning( + "Could not create child node group '%s' of type %s: %s", + nt_data.get("name", ""), + tree_type, + exc, + ) + else: + child_key = "child_" + new_node.node_tree.name + socket_id_map[child_key] = {} + deserialize_node_tree(new_node.node_tree, nt_data, socket_id_map[child_key]) # Don't process node_tree again below node_data = {k: v for k, v in node_data.items() if k != "node_tree"} @@ -387,6 +776,7 @@ def deserialize_node(node_data, node_tree, socket_id_map, defer_io=False): "texture_mapping": deserialize_texture_mapping, "mapping": deserialize_curve_mapping, "image": deserialize_image, + "file_output_slots": deserialize_output_file_slots, "inputs": lambda n, v: deserialize_inputs( n, v, node_data, node_tree, socket_id_map ), @@ -431,6 +821,10 @@ def deserialize_node(node_data, node_tree, socket_id_map, defer_io=False): elif prop_name in ("label", "type", "name"): continue else: + if isinstance(prop_value, dict) and "__id__" in prop_value: + prop_value = resolve_id_reference(prop_value) + if prop_value is None: + continue try: setattr(new_node, prop_name, prop_value) except (TypeError, AttributeError, KeyError) as exc: @@ -574,6 +968,8 @@ def deserialize_node_tree(node_tree, data, socket_id_map): data: Serialized node tree dict. socket_id_map: Mutable dict for socket identifier remapping. """ + _apply_group_interface_socket_policy(data) + nodes_data = data.get("nodes", {}) links_data = data.get("links", []) node_map = {} # old_name -> new Node @@ -694,6 +1090,11 @@ def deserialize_node_tree(node_tree, data, socket_id_map): elif prop_name == "outputs": deserialize_outputs(node, prop_value, nd, node_tree, socket_id_map) + # Restore per-node visible socket layout for Group Input/Output nodes. + # This must run after all interface sockets exist, and before links are + # created so hidden sockets are still resolvable by identifier. + _hide_group_io_sockets_per_payload_node(node_map, nodes_data, socket_id_map) + # Create links: links_created = 0 for link_data in links_data: @@ -734,10 +1135,10 @@ def _set_location_from_absolute(node, abs_loc): the correct relative location. """ if node.parent: - parent_abs = node.parent.location_absolute + parent_abs = _node_absolute_location(node.parent) node.location = ( - abs_loc[0] - parent_abs[0], - abs_loc[1] - parent_abs[1], + float(abs_loc[0]) - float(parent_abs[0]), + float(abs_loc[1]) - float(parent_abs[1]), ) else: node.location = abs_loc diff --git a/node_data.py b/node_data.py index 77322e8..47c7506 100644 --- a/node_data.py +++ b/node_data.py @@ -1,7 +1,7 @@ """Node data tables for Node Runner. Provides default values, socket names, and property defaults for Blender -shader nodes. When running inside Blender, call ``refresh()`` at addon +shader, geometry, and compositor nodes. When running inside Blender, call ``refresh()`` at addon registration to build tables from the live node system. This means the addon automatically adapts when Blender adds or changes node sockets. @@ -738,8 +738,8 @@ def _introspect_tree(bpy, tree, prefix): def refresh(): """Query Blender for current node defaults. - Call this at addon registration. Discovers all ShaderNode and - GeometryNode types, creates temporary instances, and reads their + Call this at addon registration. Discovers all ShaderNode, GeometryNode, and + CompositorNode types, creates temporary instances, and reads their default input values, socket names, and property defaults. The module-level dicts ``NODE_DEFAULTS``, ``INPUT_NAMES``, and ``OUTPUT_NAMES`` are updated in place so existing imports see the @@ -798,6 +798,36 @@ def refresh(): except (RuntimeError, AttributeError): pass + # Compositor nodes — instantiated in a temporary scene compositor tree. + scene = None + old_scene = None + try: + old_scene = getattr(bpy.context.window, "scene", None) if getattr(bpy.context, "window", None) else None + scene = bpy.data.scenes.new("_nr_tmp_compositor") + if getattr(bpy.context, "window", None) is not None: + bpy.context.window.scene = scene + scene.use_nodes = True + tree = scene.node_tree + for n in list(tree.nodes): + tree.nodes.remove(n) + d, i, o = _introspect_tree(bpy, tree, "CompositorNode") + new_defaults.update(d) + new_input_names.update(i) + new_output_names.update(o) + except (RuntimeError, AttributeError, TypeError): + pass + finally: + try: + if old_scene is not None and getattr(bpy.context, "window", None) is not None: + bpy.context.window.scene = old_scene + except (RuntimeError, AttributeError): + pass + if scene is not None: + try: + bpy.data.scenes.remove(scene) + except (RuntimeError, AttributeError): + pass + if not new_defaults: return # Query produced nothing, keep fallback tables diff --git a/operators.py b/operators.py index f9c5816..eb9fe0b 100644 --- a/operators.py +++ b/operators.py @@ -40,7 +40,7 @@ def _blender_version_string(): return f"{v[0]}.{v[1]}.{v[2]}" -_SUPPORTED_TREE_TYPES = {"ShaderNodeTree", "GeometryNodeTree"} +_SUPPORTED_TREE_TYPES = {"ShaderNodeTree", "GeometryNodeTree", "CompositorNodeTree"} # Object types that can carry a Geometry Nodes modifier. Used when the # user invokes Import on an empty GN editor so we know whether we can @@ -51,7 +51,7 @@ def _blender_version_string(): def _supported_editor_poll(context): - """True when the active editor is a Shader or Geometry Nodes editor. + """True when the active editor is a Shader, Geometry, or Compositor Nodes editor. Does NOT require an existing tree — Import will auto-create one when the editor is empty. @@ -65,7 +65,7 @@ def _supported_editor_poll(context): def _supported_tree_poll(context): - """True when the active editor has a live Shader or GN tree. + """True when the active editor has a live Shader, Geometry, or Compositor node tree. Stricter than ``_supported_editor_poll`` — used by Export, which cannot run without an existing tree to read nodes from. @@ -96,24 +96,22 @@ def _find_node_editor_tree(context, tree_type): def _ensure_default_tree(operator, context, payload_tree_type): - """Create a default tree of *payload_tree_type* and attach it to the - active object so Import has somewhere to deserialize into. + """Create or enable a default tree of *payload_tree_type* so Import has somewhere to deserialize into. Returns the new ``NodeTree``, or ``None`` if attachment isn't possible (no active object, wrong object type for GN, etc.). """ obj = getattr(context, "active_object", None) - if obj is None: - operator.report( - {"ERROR"}, - "No active object to attach a new tree to. " - "Select an object and try again.", - ) - return None - tree_name = "Imported Nodes" if payload_tree_type == "GeometryNodeTree": + if obj is None: + operator.report( + {"ERROR"}, + "No active object to attach a new Geometry Nodes modifier to. " + "Select an object and try again.", + ) + return None if obj.type not in _GN_OBJECT_TYPES: operator.report( {"ERROR"}, @@ -135,7 +133,16 @@ def _ensure_default_tree(operator, context, payload_tree_type): gi.location = (-200, 0) go = ng.nodes.new("NodeGroupOutput") go.location = (200, 0) - ng.links.new(gi.outputs[0], go.inputs[0]) + # Blender 4.x documents NodeLinks.new(input_socket, output_socket). + # Some older examples use output,input, so try the documented order + # first and fall back without aborting the import. + try: + ng.links.new(go.inputs[0], gi.outputs[0]) + except (RuntimeError, TypeError, ValueError): + try: + ng.links.new(gi.outputs[0], go.inputs[0]) + except (RuntimeError, TypeError, ValueError): + log.debug("Could not seed Geometry passthrough link") mod = obj.modifiers.new(name="GeometryNodes", type="NODES") mod.node_group = ng operator.report( @@ -145,6 +152,13 @@ def _ensure_default_tree(operator, context, payload_tree_type): return ng if payload_tree_type == "ShaderNodeTree": + if obj is None: + operator.report( + {"ERROR"}, + "No active object to attach a new material to. " + "Select an object and try again.", + ) + return None if not hasattr(obj.data, "materials"): operator.report( {"ERROR"}, @@ -169,6 +183,23 @@ def _ensure_default_tree(operator, context, payload_tree_type): ) return mat.node_tree + if payload_tree_type == "CompositorNodeTree": + scene = getattr(context, "scene", None) + if scene is None: + operator.report({"ERROR"}, "No active scene for compositor nodes") + return None + try: + scene.use_nodes = True + except (TypeError, AttributeError, RuntimeError) as exc: + operator.report({"ERROR"}, f"Could not enable compositor nodes: {exc}") + return None + tree = getattr(scene, "node_tree", None) + if tree is None: + operator.report({"ERROR"}, "Could not access the scene compositor node tree") + return None + operator.report({"INFO"}, "Enabled compositor nodes for the active scene") + return tree + return None @@ -300,6 +331,11 @@ def _do_import(operator, context, raw, mouse_x=None, mouse_y=None): bpy.types.WindowManager.nr_pending_data = data bpy.types.WindowManager.nr_pending_mouse = (mouse_x, mouse_y) bpy.types.WindowManager.nr_pending_auto_created = auto_created + # Keep a direct reference to the target tree. In Blender 4.2 the + # node editor may not have refreshed immediately after we auto-create + # a material/modifier, so relying only on context.space_data.edit_tree + # can make the confirmation step import into None. + bpy.types.WindowManager.nr_pending_edit_tree = edit_tree return bpy.ops.node_runner.confirm_import( "INVOKE_DEFAULT", export_version=export_version, @@ -346,39 +382,40 @@ def _apply_import( data.pop("export_name", None) data.pop("blender_version", None) - # When we auto-created the tree, the seeded Group Input/Output and - # passthrough Geometry interface socket exist only so the modifier - # could bind to a valid tree. If the payload brings its own Group - # I/O, clear the seeds so they don't end up as duplicates. If the - # payload only contains body nodes (a partial export), keep the - # seeds — otherwise the tree would have no Group Output and the - # modifier would error. + # When we auto-created/auto-enabled the tree, remove Blender's seed + # nodes only where they are implementation scaffolding. Geometry Nodes + # needs a temporary passthrough group to bind a modifier; Compositor + # Nodes creates Render Layers + Composite when scene.use_nodes is enabled. if auto_created: - node_types = { - nd.get("type") for nd in data.get("nodes", {}).values() - } - payload_has_output = "NodeGroupOutput" in node_types - payload_has_input = "NodeGroupInput" in node_types - if payload_has_output: - for node in list(edit_tree.nodes): - if node.bl_idname == "NodeGroupOutput": - edit_tree.nodes.remove(node) - if payload_has_input: + if edit_tree.bl_idname == "CompositorNodeTree": for node in list(edit_tree.nodes): - if node.bl_idname == "NodeGroupInput": - edit_tree.nodes.remove(node) - # Strip seeded interface sockets that the payload's Group I/O - # will recreate. Only the matched directions are wiped. - if hasattr(edit_tree, "interface") and ( - payload_has_input or payload_has_output - ): - for item in list(edit_tree.interface.items_tree): - if item.item_type != "SOCKET": - continue - if item.in_out == "INPUT" and payload_has_input: - edit_tree.interface.remove(item) - elif item.in_out == "OUTPUT" and payload_has_output: - edit_tree.interface.remove(item) + edit_tree.nodes.remove(node) + else: + node_types = { + nd.get("type") for nd in data.get("nodes", {}).values() + } + payload_has_output = "NodeGroupOutput" in node_types + payload_has_input = "NodeGroupInput" in node_types + if payload_has_output: + for node in list(edit_tree.nodes): + if node.bl_idname == "NodeGroupOutput": + edit_tree.nodes.remove(node) + if payload_has_input: + for node in list(edit_tree.nodes): + if node.bl_idname == "NodeGroupInput": + edit_tree.nodes.remove(node) + # Strip seeded interface sockets that the payload's Group I/O + # will recreate. Only the matched directions are wiped. + if hasattr(edit_tree, "interface") and ( + payload_has_input or payload_has_output + ): + for item in list(edit_tree.interface.items_tree): + if item.item_type != "SOCKET": + continue + if item.in_out == "INPUT" and payload_has_input: + edit_tree.interface.remove(item) + elif item.in_out == "OUTPUT" and payload_has_output: + edit_tree.interface.remove(item) # Deselect all existing nodes for node in edit_tree.nodes: @@ -851,6 +888,7 @@ def execute(self, context): auto_created = getattr( bpy.types.WindowManager, "nr_pending_auto_created", False ) + edit_tree = getattr(bpy.types.WindowManager, "nr_pending_edit_tree", None) if data is None: self.report({"ERROR"}, "No pending import data") @@ -861,9 +899,12 @@ def execute(self, context): del bpy.types.WindowManager.nr_pending_mouse if hasattr(bpy.types.WindowManager, "nr_pending_auto_created"): del bpy.types.WindowManager.nr_pending_auto_created + if hasattr(bpy.types.WindowManager, "nr_pending_edit_tree"): + del bpy.types.WindowManager.nr_pending_edit_tree return _apply_import( - self, context, data, mouse[0], mouse[1], auto_created=auto_created, + self, context, data, mouse[0], mouse[1], + edit_tree=edit_tree, auto_created=auto_created, ) @@ -934,6 +975,12 @@ def register(): def unregister(): - bpy.types.NODE_MT_context_menu.remove(menu_draw) + try: + bpy.types.NODE_MT_context_menu.remove(menu_draw) + except (AttributeError, ValueError, RuntimeError): + pass for cls in reversed(_classes): - bpy.utils.unregister_class(cls) + try: + bpy.utils.unregister_class(cls) + except RuntimeError: + pass diff --git a/serialize.py b/serialize.py index 76b9e2a..0b7fff0 100644 --- a/serialize.py +++ b/serialize.py @@ -15,6 +15,29 @@ log = logging.getLogger(__name__) +def _node_absolute_location(node): + """Return node location in absolute canvas coordinates. + + Blender 4.5 exposes ``Node.location_absolute`` for this directly, but + Blender 4.2 does not expose it on all node classes. Reconstruct it + from ``location`` and parent frames so export still works in 4.2. + """ + loc_abs = getattr(node, "location_absolute", None) + if loc_abs is not None: + return [float(loc_abs[0]), float(loc_abs[1])] + + loc = getattr(node, "location", (0.0, 0.0)) + x = float(loc[0]) if len(loc) > 0 else 0.0 + y = float(loc[1]) if len(loc) > 1 else 0.0 + + parent = getattr(node, "parent", None) + if parent is not None: + parent_abs = _node_absolute_location(parent) + x += float(parent_abs[0]) + y += float(parent_abs[1]) + + return [x, y] + # Primitive / math type serializers @@ -126,6 +149,43 @@ def serialize_image(image): return result +def serialize_id_reference(id_block): + """Serialize a Blender ID pointer by type and name. + + This is especially useful for compositor nodes such as Render Layers, + Movie Clip, and Mask nodes. The importer will resolve the reference by + name if the target .blend already contains the same data-block. + """ + if id_block is None: + return None + return {"__id__": type(id_block).__name__, "name": getattr(id_block, "name", "")} + + +def serialize_output_file_slots(node): + """Serialize compositor File Output node slot layout defensively.""" + result = {} + for collection_name in ("file_slots", "layer_slots"): + slots = getattr(node, collection_name, None) + if slots is None: + continue + entries = [] + try: + iterator = list(slots) + except (TypeError, RuntimeError): + continue + for slot in iterator: + entry = {} + for attr_name in ("name", "path", "use_node_format", "save_as_render"): + if hasattr(slot, attr_name): + try: + entry[attr_name] = getattr(slot, attr_name) + except (TypeError, AttributeError, RuntimeError): + pass + entries.append(entry) + result[collection_name] = entries + return result + + def serialize_text_line(text_line): """Serialize one TextLine.""" return {"body": text_line.body} @@ -155,33 +215,55 @@ def serialize_attr(node, attr): Falls back to returning the value directly if it is pickle-safe. Logs a warning for unsupported types. """ - # Dispatch table: type -> serializer callable + # Dispatch table: type -> serializer callable. Build it defensively so + # Blender 4.2 does not fail if a class name from a newer Blender build is + # absent. _dispatch = { mathutils.Color: serialize_color, mathutils.Vector: serialize_vector, mathutils.Euler: serialize_euler, - bpy.types.ColorRamp: lambda _: serialize_color_ramp(node), - bpy.types.NodeTree: lambda _: serialize_node_tree(node.node_tree), - bpy.types.ColorMapping: lambda _: serialize_color_mapping(node), - bpy.types.TexMapping: lambda _: serialize_texture_mapping(node), - bpy.types.CurveMapping: lambda _: serialize_curve_mapping(node), - bpy.types.CurveMap: lambda d: serialize_curve_map(node, d), - bpy.types.CurveMapPoint: lambda d: serialize_curve_map_point(node, d), - bpy.types.Image: serialize_image, - bpy.types.ImageUser: lambda _: {}, - bpy.types.NodeFrame: lambda _: {}, # Handled separately - bpy.types.Text: lambda _: serialize_text(node.script), - bpy.types.Object: lambda _: None, - bpy.types.NodeSocketStandard: lambda d: ( + } + + def _add_type(type_name, serializer): + data_type = getattr(bpy.types, type_name, None) + if data_type is not None: + _dispatch[data_type] = serializer + + _add_type("ColorRamp", lambda _: serialize_color_ramp(node)) + _add_type("NodeTree", lambda _: serialize_node_tree(node.node_tree)) + _add_type("ColorMapping", lambda _: serialize_color_mapping(node)) + _add_type("TexMapping", lambda _: serialize_texture_mapping(node)) + _add_type("CurveMapping", lambda _: serialize_curve_mapping(node)) + _add_type("CurveMap", lambda d: serialize_curve_map(node, d)) + _add_type("CurveMapPoint", lambda d: serialize_curve_map_point(node, d)) + _add_type("Image", serialize_image) + for _id_type in ( + "Scene", + "MovieClip", + "Mask", + "Object", + "Collection", + "Material", + "Texture", + "World", + ): + _add_type(_id_type, serialize_id_reference) + _add_type("ImageUser", lambda _: {}) + _add_type("NodeFrame", lambda _: {}) # Handled separately + _add_type("Text", lambda _: serialize_text(node.script)) + _add_type( + "NodeSocketStandard", + lambda d: ( serialize_attr(node, d.default_value) if hasattr(d, "default_value") else None ), - bpy.types.bpy_prop_collection: lambda d: [ - serialize_attr(node, el) for el in d.values() - ], - bpy.types.bpy_prop_array: lambda d: [serialize_attr(node, el) for el in d], - } + ) + _add_type( + "bpy_prop_collection", + lambda d: [serialize_attr(node, el) for el in d.values()], + ) + _add_type("bpy_prop_array", lambda d: [serialize_attr(node, el) for el in d]) for data_type, serializer in _dispatch.items(): if isinstance(attr, data_type): @@ -218,6 +300,129 @@ def _socket_entry(node, s, iface_by_id): return entry +def _prune_group_interface_orders_to_exported_links(data): + """Trim Group Input/Output interface orders to linked sockets only. + + A Group Input node in Blender shows every INPUT socket of the group + interface, regardless of how many of those sockets the selected snippet + actually uses. This keeps copied snippets compact on paste. + """ + nodes = data.get("nodes", {}) + used_group_input_outputs = {} + used_group_output_inputs = {} + + for link in data.get("links", []): + from_name = link.get("from_node") + to_name = link.get("to_node") + from_node = nodes.get(from_name, {}) + to_node = nodes.get(to_name, {}) + + if from_node.get("type") == "NodeGroupInput": + ident = link.get("from_socket_identifier") + if ident: + used_group_input_outputs.setdefault(from_name, set()).add(ident) + + if to_node.get("type") == "NodeGroupOutput": + ident = link.get("to_socket_identifier") + if ident: + used_group_output_inputs.setdefault(to_name, set()).add(ident) + + for node_name, node_data in nodes.items(): + node_type = node_data.get("type") + if node_type == "NodeGroupInput": + used = used_group_input_outputs.get(node_name, set()) + if "output_order" in node_data: + node_data["output_order"] = [ + item + for item in node_data.get("output_order", []) + if item.get("identifier") in used + ] + elif node_type == "NodeGroupOutput": + used = used_group_output_inputs.get(node_name, set()) + if "input_order" in node_data: + node_data["input_order"] = [ + item + for item in node_data.get("input_order", []) + if item.get("identifier") in used + ] + + +def _compact_group_input_nodes_in_payload(data): + """Collapse selected-snippet Group Input instances into one payload node.""" + nodes = data.get("nodes", {}) + links = data.get("links", []) + group_input_names = [ + name for name, node_data in nodes.items() + if node_data.get("type") == "NodeGroupInput" + ] + if len(group_input_names) <= 1: + return + + linked_from = { + link.get("from_node") + for link in links + if nodes.get(link.get("from_node"), {}).get("type") == "NodeGroupInput" + } + if not linked_from: + for name in group_input_names: + nodes.pop(name, None) + return + + def used_count(name): + return sum(1 for link in links if link.get("from_node") == name) + + def loc_x(name): + loc = nodes.get(name, {}).get("location_absolute") or nodes.get(name, {}).get("location") or [0, 0] + try: + return float(loc[0]) + except (TypeError, ValueError, IndexError): + return 0.0 + + primary = sorted(linked_from, key=lambda n: (-used_count(n), loc_x(n), n))[0] + primary_data = nodes[primary] + + used_identifiers = { + link.get("from_socket_identifier") + for link in links + if nodes.get(link.get("from_node"), {}).get("type") == "NodeGroupInput" + and link.get("from_socket_identifier") + } + + order = {} + i = 0 + by_identifier = {} + for name in group_input_names: + for item in nodes.get(name, {}).get("output_order", []): + ident = item.get("identifier") + if ident and ident not in order: + order[ident] = i + i += 1 + if ident in used_identifiers and ident not in by_identifier: + by_identifier[ident] = item + + for link in links: + if nodes.get(link.get("from_node"), {}).get("type") != "NodeGroupInput": + continue + ident = link.get("from_socket_identifier") + if ident and ident not in by_identifier: + by_identifier[ident] = { + "type": link.get("from_socket_type", "NodeSocketFloat"), + "name": link.get("from_socket", ""), + "identifier": ident, + } + link["from_node"] = primary + + primary_data["output_order"] = sorted( + by_identifier.values(), key=lambda item: order.get(item.get("identifier"), 10_000) + ) + + for name in group_input_names: + if name != primary: + nodes.pop(name, None) + + data["group_input_policy"] = "single_compact" + + def serialize_node(node): """Serialize all properties of a single node into a dict. @@ -276,8 +481,15 @@ def serialize_node(node): node_dict["type"] = node.bl_idname node_dict["label"] = node.label - # Store absolute location for correct nested-frame positioning - node_dict["location_absolute"] = list(node.location_absolute) + # Store absolute location for correct nested-frame positioning. + # Blender 4.2 does not expose node.location_absolute on all nodes. + node_dict["location_absolute"] = _node_absolute_location(node) + + # Compositor File Output nodes store their per-input slot names/paths in + # a collection, not in normal socket default values. Preserve that layout + # so pasted compositor setups keep their output passes organized. + if node.bl_idname == "CompositorNodeOutputFile": + node_dict["file_output_slots"] = serialize_output_file_slots(node) # Store paired output reference for zone nodes (repeat, simulation, etc.) if node.bl_idname in PAIRED_NODE_TYPES: @@ -350,5 +562,19 @@ def serialize_node_tree(node_tree, selected_node_names=None): } ) + # Node Runner exports selected nodes. For Group Input/Output nodes, + # Blender exposes the entire group interface on every instance. If we + # serialize the entire interface for a small selected-node export, importing + # it recreates many unrelated/empty sockets. Keep only interface sockets + # actually used by the exported links. + if selected_node_names is not None: + data["interface_socket_policy"] = "linked_only" + # Keep each Group Input node as a real layout node. Blender's group + # interface is global, but individual Group Input socket visibility is + # per-node, so the importer can recreate the original compact scattered + # Group Input layout by hiding outputs that do not belong to each node. + _prune_group_interface_orders_to_exported_links(data) + data["group_input_policy"] = "per_node_hidden_outputs" + log.debug("Serialized %d links", len(data["links"])) return data