From 49cb16338d24c037116ce7d9f6c6b9de42c2cc70 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Fri, 14 Aug 2026 13:07:58 +0000 Subject: [PATCH] Add Dumas character helper node --- README.md | 8 ++ dumas_image_nodes.py | 191 ++++++++++++++++++++++++++++++++ tests/test_dumas_image_nodes.py | 58 ++++++++++ 3 files changed, 257 insertions(+) diff --git a/README.md b/README.md index 6f17e55..f9424ac 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,12 @@ - Outputs: `plan`, `image1`..`image7`, `connected_images` - Reads back the seven optional images for a selected MiniMax H3 plan scene, for example by connecting the current `clip_index`. +- `Dumas Character Helper` + - Inputs: `image1`, `image2`, `image1_picture_id`, `image2_picture_id`, `character_id`, `name`, `height_feet`, `height_inches`, `accent`, `general` + - Outputs: `image1`, `image2`, `character_text` + - Passes both images through unchanged and builds a character reference string such as ` and reference the same character who is called Dave.` + - Appends optional sentences for character ID, height, accent, and freeform notes only when those fields are filled in. + - `Dumas JSON String to Object` - Input: `json_string` - Output: parsed `JSON` @@ -183,6 +189,8 @@ decr -> use index - 1 `Dumas H3 Plan Attach Scene Images` and `Dumas H3 Plan Extract Scene Images` are a companion pair for `ComfyUI-MiniMaxH3-Contex-Loop`. The upstream H3 plan node cannot dynamically grow six new image sockets for every JSON-defined scene, so Dumas stores scene image bindings beside the plan using a lightweight token and an in-memory registry. That keeps `plan.json` archiving intact while still letting you wire up six IMAGE sockets per scene through chained helper nodes. +`Dumas Character Helper` lives in `Dumas/String`. Use the picture ID dropdowns to decide which `` tags get mentioned in the generated text, while the two IMAGE sockets continue downstream unchanged. + `Dumas Strip Iteration Suffix` keeps the part before the first underscore and drops the rest. Names like `char123_pose_final.png` become `char123.png`, while names with no underscore such as `char123.png` are left untouched. `Dumas Slugify String` lowercases text, strips accents, replaces non-alphanumeric runs with `-`, and trims leading or trailing dashes. diff --git a/dumas_image_nodes.py b/dumas_image_nodes.py index 426142b..68773e1 100644 --- a/dumas_image_nodes.py +++ b/dumas_image_nodes.py @@ -255,6 +255,90 @@ def _tensor_image_to_pil_image(tensor): return Image.fromarray(np.clip(image_array, 0, 255).astype(np.uint8)) +def _normalize_free_text(value): + return " ".join(str(value or "").split()).strip() + + +def _label_for_character(name, character_id): + return _normalize_free_text(name) or _normalize_free_text(character_id) or "the character" + + +def _format_height_text(feet, inches): + feet_value = str(feet or "").strip() + inches_value = str(inches or "").strip() + if not feet_value and not inches_value: + return "" + if not feet_value: + return f'{int(inches_value)}"' + if not inches_value: + return f"{int(feet_value)}'" + return f'{int(feet_value)}\'{int(inches_value)}"' + + +def _ensure_sentence(value): + text = _normalize_free_text(value) + if not text: + return "" + if text[-1] not in ".!?": + text += "." + return text + + +def _build_character_helper_text( + primary_picture_id, + secondary_picture_id, + character_id, + name, + height_feet, + height_inches, + accent, + general, +): + primary_picture = int(primary_picture_id) + secondary_picture = int(secondary_picture_id) + character_name = _normalize_free_text(name) + character_id = _normalize_free_text(character_id) + accent = _normalize_free_text(accent) + general = _ensure_sentence(general) + character_label = _label_for_character(character_name, character_id) + + if character_name: + first_line = ( + f" and reference " + f"the same character who is called {character_name}." + ) + elif character_id: + first_line = ( + f" and reference " + f'the same character with ID "{character_id}".' + ) + else: + first_line = ( + f" and reference " + "the same character." + ) + + lines = [ + first_line, + f" is the primary full-body reference for {character_label}.", + ] + + if character_id: + lines.append(f'The character ID string is "{character_id}".') + + height_text = _format_height_text(height_feet, height_inches) + if height_text: + lines.append(f"{character_label} is {height_text} tall.") + + if accent: + lines.append(f"{character_label} has a {accent} accent.") + + if general: + lines.append(general) + + return "\n".join(lines) + + class DumasImageCompareNode: DESCRIPTION = ( "Dumas Image Compare shows the difference between two images directly on " @@ -661,11 +745,117 @@ class DumasH3PlanExtractSceneImagesNode: return (passthrough_plan, *images, _connected_image_count(images)) +class DumasCharacterHelperNode: + DESCRIPTION = ( + "Build a reusable character reference string from two IMAGE sockets and " + "simple identity fields, while passing both images through unchanged." + ) + RETURN_TYPES = ("IMAGE", "IMAGE", "STRING") + RETURN_NAMES = ("image1", "image2", "character_text") + FUNCTION = "build_character_text" + CATEGORY = "Dumas/String" + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image1": ("IMAGE", {"tooltip": "Primary image to pass through and describe."}), + "image2": ("IMAGE", {"tooltip": "Secondary image to pass through and describe."}), + "image1_picture_id": ( + ["2", "3", "4", "5", "6", "7"], + { + "default": "2", + "tooltip": "Picture number to mention for image1 in the output string.", + }, + ), + "image2_picture_id": ( + ["2", "3", "4", "5", "6", "7"], + { + "default": "3", + "tooltip": "Picture number to mention for image2 in the output string.", + }, + ), + "character_id": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": "Optional character ID string to include in the output text.", + }, + ), + "name": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": "Character name used in the main reference sentences.", + }, + ), + "height_feet": ( + ["", "3", "4", "5", "6", "7", "8"], + { + "default": "", + "tooltip": "Optional feet component for the character's height.", + }, + ), + "height_inches": ( + ["", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], + { + "default": "", + "tooltip": "Optional inches component for the character's height.", + }, + ), + "accent": ( + "STRING", + { + "default": "", + "multiline": False, + "tooltip": "Optional short accent description.", + }, + ), + "general": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": "Optional freeform details appended as the last sentence.", + }, + ), + } + } + + def build_character_text( + self, + image1, + image2, + image1_picture_id, + image2_picture_id, + character_id, + name, + height_feet, + height_inches, + accent, + general, + ): + text = _build_character_helper_text( + image1_picture_id, + image2_picture_id, + character_id, + name, + height_feet, + height_inches, + accent, + general, + ) + return (image1, image2, text) + + NODE_CLASS_MAPPINGS = { "DumasImageCompare": DumasImageCompareNode, "DumasSaveImage": DumasSaveImageNode, "DumasH3PlanAttachSceneImages": DumasH3PlanAttachSceneImagesNode, "DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode, + "DumasCharacterHelper": DumasCharacterHelperNode, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -673,4 +863,5 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DumasSaveImage": "Save Image Dumas", "DumasH3PlanAttachSceneImages": "Dumas H3 Plan Attach Scene Images", "DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images", + "DumasCharacterHelper": "Dumas Character Helper", } diff --git a/tests/test_dumas_image_nodes.py b/tests/test_dumas_image_nodes.py index dc880c9..5a9ac86 100644 --- a/tests/test_dumas_image_nodes.py +++ b/tests/test_dumas_image_nodes.py @@ -239,6 +239,64 @@ class DumasImageNodeTests(unittest.TestCase): saved_path = FakePILImage.saved_paths[0][0] self.assertTrue(os.path.isdir(os.path.dirname(saved_path))) + def test_character_helper_passes_through_images_and_formats_text(self): + node = self.image_nodes.DumasCharacterHelperNode() + image1 = FakeTensorBatch() + image2 = FakeTensorBatch() + + result = node.build_character_text( + image1=image1, + image2=image2, + image1_picture_id="2", + image2_picture_id="3", + character_id="char_dave", + name="Dave", + height_feet="6", + height_inches="2", + accent="northern English", + general="wears a long grey coat", + ) + + self.assertIs(result[0], image1) + self.assertIs(result[1], image2) + self.assertEqual( + result[2], + ( + " and reference the same character who is called Dave.\n" + " is the primary full-body reference for Dave.\n" + 'The character ID string is "char_dave".\n' + "Dave is 6'2\" tall.\n" + "Dave has a northern English accent.\n" + "wears a long grey coat." + ), + ) + + def test_character_helper_handles_missing_optional_fields(self): + node = self.image_nodes.DumasCharacterHelperNode() + image1 = FakeTensorBatch() + image2 = FakeTensorBatch() + + result = node.build_character_text( + image1=image1, + image2=image2, + image1_picture_id="4", + image2_picture_id="6", + character_id="", + name="", + height_feet="", + height_inches="", + accent="", + general="", + ) + + self.assertEqual( + result[2], + ( + " and reference the same character.\n" + " is the primary full-body reference for the character." + ), + ) + def test_save_image_returns_ui_entries_for_output_folder(self): node = self.image_nodes.DumasSaveImageNode() image = FakeTensorBatch()