Add Dumas character helper node
This commit is contained in:
@@ -27,6 +27,12 @@
|
|||||||
- Outputs: `plan`, `image1`..`image7`, `connected_images`
|
- 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`.
|
- 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 `<Picture 2> and <Picture 3> 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`
|
- `Dumas JSON String to Object`
|
||||||
- Input: `json_string`
|
- Input: `json_string`
|
||||||
- Output: parsed `JSON`
|
- 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 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 `<Picture N>` 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 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.
|
`Dumas Slugify String` lowercases text, strips accents, replaces non-alphanumeric runs with `-`, and trims leading or trailing dashes.
|
||||||
|
|||||||
@@ -255,6 +255,90 @@ def _tensor_image_to_pil_image(tensor):
|
|||||||
return Image.fromarray(np.clip(image_array, 0, 255).astype(np.uint8))
|
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"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
|
||||||
|
f"the same character who is called {character_name}."
|
||||||
|
)
|
||||||
|
elif character_id:
|
||||||
|
first_line = (
|
||||||
|
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
|
||||||
|
f'the same character with ID "{character_id}".'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
first_line = (
|
||||||
|
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
|
||||||
|
"the same character."
|
||||||
|
)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
first_line,
|
||||||
|
f"<Picture {primary_picture}> 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:
|
class DumasImageCompareNode:
|
||||||
DESCRIPTION = (
|
DESCRIPTION = (
|
||||||
"Dumas Image Compare shows the difference between two images directly on "
|
"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))
|
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 = {
|
NODE_CLASS_MAPPINGS = {
|
||||||
"DumasImageCompare": DumasImageCompareNode,
|
"DumasImageCompare": DumasImageCompareNode,
|
||||||
"DumasSaveImage": DumasSaveImageNode,
|
"DumasSaveImage": DumasSaveImageNode,
|
||||||
"DumasH3PlanAttachSceneImages": DumasH3PlanAttachSceneImagesNode,
|
"DumasH3PlanAttachSceneImages": DumasH3PlanAttachSceneImagesNode,
|
||||||
"DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode,
|
"DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode,
|
||||||
|
"DumasCharacterHelper": DumasCharacterHelperNode,
|
||||||
}
|
}
|
||||||
|
|
||||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||||
@@ -673,4 +863,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
|||||||
"DumasSaveImage": "Save Image Dumas",
|
"DumasSaveImage": "Save Image Dumas",
|
||||||
"DumasH3PlanAttachSceneImages": "Dumas H3 Plan Attach Scene Images",
|
"DumasH3PlanAttachSceneImages": "Dumas H3 Plan Attach Scene Images",
|
||||||
"DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images",
|
"DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images",
|
||||||
|
"DumasCharacterHelper": "Dumas Character Helper",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -239,6 +239,64 @@ class DumasImageNodeTests(unittest.TestCase):
|
|||||||
saved_path = FakePILImage.saved_paths[0][0]
|
saved_path = FakePILImage.saved_paths[0][0]
|
||||||
self.assertTrue(os.path.isdir(os.path.dirname(saved_path)))
|
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],
|
||||||
|
(
|
||||||
|
"<Picture 2> and <Picture 3> reference the same character who is called Dave.\n"
|
||||||
|
"<Picture 2> 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],
|
||||||
|
(
|
||||||
|
"<Picture 4> and <Picture 6> reference the same character.\n"
|
||||||
|
"<Picture 4> is the primary full-body reference for the character."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
def test_save_image_returns_ui_entries_for_output_folder(self):
|
def test_save_image_returns_ui_entries_for_output_folder(self):
|
||||||
node = self.image_nodes.DumasSaveImageNode()
|
node = self.image_nodes.DumasSaveImageNode()
|
||||||
image = FakeTensorBatch()
|
image = FakeTensorBatch()
|
||||||
|
|||||||
Reference in New Issue
Block a user