Compare commits

...
2 Commits
Author SHA1 Message Date
chris.dumas dd8ef84379 Add character helper reference outputs 2026-09-05 18:00:17 +00:00
chris.dumas d20b257134 Add H3 prompt subject count guard 2026-09-05 17:54:43 +00:00
3 changed files with 171 additions and 9 deletions
+5 -4
View File
@@ -57,11 +57,11 @@
- Builds one H3 prompt block per beat, with quick controls for per-shot timing, continuity, ref behavior, anchor additions, soundscape, and music while staying compatible with direct text editing.
- `Dumas H3 Prompt Curator`
- Inputs: `action_prompt`, `anatomy_guard`, optional `anchor`, optional `soundscape`, optional `ref_1` through `ref_9`
- Inputs: `action_prompt`, `anatomy_guard`, `subject_count_guard`, optional `anchor`, optional `soundscape`, optional `ref_1` through `ref_9`
- Outputs: `prompt`, `ref_image_1` through `ref_image_9`, `reference_count`, `debug`
- Builds one standalone MiniMax H3 prompt from your final action text plus structured character/location references.
- The action text can mention references by character/location name, alias, `<Picture N>`, or `<refN>`. Only mentioned references are emitted, and the output images are compacted/renumbered so skipped inputs do not leave gaps.
- Adds curated reference context, optional anatomy guard text, anchor/style text, and `overall_soundscape:` text while respecting MiniMax H3's reference-generation shape: one prompt plus up to nine reference images.
- Adds curated reference context, anatomy guard text, optional subject-count guard text, anchor/style text, and `overall_soundscape:` text while respecting MiniMax H3's reference-generation shape: one prompt plus up to nine reference images.
- `Dumas H3 Shot Length`
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
@@ -85,8 +85,9 @@
- `Dumas Character Helper`
- Inputs: `image1`, `image2`, picture IDs, character identity fields, `general`, `wardrobe`
- Outputs: `image1`, `image2`, `reference_prompt`, `wardrobe`
- Restores the original general-purpose helper shape: pass two images through unchanged and emit prompt text/wardrobe text for manual wiring.
- Outputs: `image1`, `image2`, `reference_prompt`, `wardrobe`, `reference1`, `reference2`
- Restores the original general-purpose helper shape while also emitting two structured `REFERENCE` objects for the prompt curator.
- The structured references carry the same character name, alias, age, height, gender, nationality, occupation, accent, wardrobe, and notes, so mentioning the character name in `Dumas H3 Prompt Curator` can include both helper images and the character facts automatically.
- `Dumas Location Helper`
- Inputs: `image1`, `image2`, picture IDs, `location_id`, `name`, `alias`, `description`, `general`
+92 -5
View File
@@ -36,6 +36,10 @@ _ANATOMY_GUARD_TEXT = (
"and two legs with two feet. Limbs stay attached to the correct body and move "
"only with the person they belong to."
)
_SUBJECT_COUNT_FALLBACK_TEXT = (
"Only include the people explicitly described in the action. Do not invent "
"extra people, doubles, duplicate bodies, background performers, or extra faces."
)
_ANCHOR_STYLE_PRESETS = OrderedDict(
[
(
@@ -1131,13 +1135,51 @@ def _reference_context(ref, compact_picture_number):
return " ".join(part for part in parts if part).strip()
def _subject_count_guard_text(selected_refs):
character_labels = []
seen_characters = set()
for picture_number, (_slot, ref) in enumerate(selected_refs or (), 1):
if ref.get("kind") != "character":
continue
name = _reference_text(ref.get("name")) or _reference_text(ref.get("id"))
key = (_reference_text(ref.get("id")) or name or f"picture-{picture_number}").lower()
if key in seen_characters:
continue
seen_characters.add(key)
label = f"<Picture {picture_number}>"
if name:
label = f"{label} {name}"
character_labels.append(label)
if not character_labels:
return _SUBJECT_COUNT_FALLBACK_TEXT
if len(character_labels) == 1:
return (
f"The shot contains exactly one named character: {character_labels[0]}. "
"Do not create any extra people, doubles, duplicate bodies, background "
"performers, or extra faces."
)
return (
f"The shot contains exactly {len(character_labels)} named characters: "
+ ", ".join(character_labels)
+ ". Do not create any extra people, doubles, duplicate bodies, background "
"performers, or extra faces."
)
def _append_prompt_section(parts, label, text):
clean = _reference_text(text)
if clean:
parts.append(f"{label}: {clean}")
def curate_h3_prompt(action_prompt, anchor="", soundscape="", refs=(), anatomy_guard="auto"):
def curate_h3_prompt(
action_prompt,
anchor="",
soundscape="",
refs=(),
anatomy_guard="auto",
subject_count_guard="auto",
):
normalized_refs = _normalize_prompt_refs(refs)
selected = _selected_prompt_refs(action_prompt, normalized_refs)
picture_map = {slot_number: index for index, (slot_number, _ref) in enumerate(selected, 1)}
@@ -1151,6 +1193,10 @@ def curate_h3_prompt(action_prompt, anchor="", soundscape="", refs=(), anatomy_g
_append_prompt_section(prompt_parts, "Action", action)
if anatomy_guard == "on" or (anatomy_guard == "auto" and any(ref.get("kind") == "character" for _slot, ref in selected)):
_append_prompt_section(prompt_parts, "Anatomy guard", _ANATOMY_GUARD_TEXT)
if subject_count_guard == "on" or (
subject_count_guard == "auto" and any(ref.get("kind") == "character" for _slot, ref in selected)
):
_append_prompt_section(prompt_parts, "Subject count guard", _subject_count_guard_text(selected))
_append_prompt_section(prompt_parts, "overall_soundscape", soundscape)
prompt = "\n\n".join(prompt_parts).strip()
@@ -1919,8 +1965,8 @@ class DumasCharacterHelperNode:
"IMAGE sockets plus simple identity fields, while passing both images "
"through unchanged."
)
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING", "STRING")
RETURN_NAMES = ("image1", "image2", "reference_prompt", "wardrobe")
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING", "STRING", _REFERENCE_TYPE, _REFERENCE_TYPE)
RETURN_NAMES = ("image1", "image2", "reference_prompt", "wardrobe", "reference1", "reference2")
FUNCTION = "build_character_text"
CATEGORY = "Dumas/MiniMax"
@@ -2081,7 +2127,36 @@ class DumasCharacterHelperNode:
name,
alias,
)
return (image1, image2, text, wardrobe_text)
facts = {
"gender": _normalize_free_text(gender),
"age": str(_parse_positive_int(age) or ""),
"nationality": _normalize_free_text(nationality),
"occupation": _normalize_free_text(occupation),
"height_feet": str(height_feet or "").strip(),
"height_inches": str(height_inches or "").strip(),
"accent": _normalize_free_text(accent),
}
common = {
"kind": "character",
"explicit_id": character_id,
"name": name,
"aliases": alias,
"description": general,
"wardrobe": wardrobe,
"general": general,
"facts": facts,
}
reference1 = make_reference(
image=image1,
summary="Primary full-body character reference.",
**common,
)
reference2 = make_reference(
image=image2,
summary="Secondary facial character reference.",
**common,
)
return (image1, image2, text, wardrobe_text, reference1, reference2)
class DumasLocationHelperNode:
@@ -2526,12 +2601,22 @@ class DumasH3PromptCuratorNode:
"anatomy_guard": (
["auto", "on", "off"],
{
"default": "auto",
"default": "on",
"tooltip": (
"Add the anatomy guard. Auto adds it when a character reference is used."
),
},
),
"subject_count_guard": (
["auto", "on", "off"],
{
"default": "auto",
"tooltip": (
"Add a guard against extra people, duplicate bodies, or extra faces. "
"Auto adds it when a character reference is used."
),
},
),
},
"optional": optional,
}
@@ -2540,6 +2625,7 @@ class DumasH3PromptCuratorNode:
self,
action_prompt,
anatomy_guard,
subject_count_guard,
anchor="",
soundscape="",
ref_1=None,
@@ -2558,6 +2644,7 @@ class DumasH3PromptCuratorNode:
soundscape=soundscape,
refs=(ref_1, ref_2, ref_3, ref_4, ref_5, ref_6, ref_7, ref_8, ref_9),
anatomy_guard=anatomy_guard,
subject_count_guard=subject_count_guard,
)
+74
View File
@@ -386,6 +386,17 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIn("Dave is also known as The Locksmith", result[2])
self.assertIn("is 41 years old", result[2])
self.assertEqual(result[3], "Dave = weathered red flight jacket, grey cargo shorts, black boots")
self.assertIs(result[4]["image"], image1)
self.assertIs(result[5]["image"], image2)
self.assertEqual(result[4]["id"], "char-dave")
self.assertEqual(result[5]["id"], "char-dave")
self.assertEqual(result[4]["name"], "Dave")
self.assertEqual(result[4]["aliases"], ["The Locksmith"])
self.assertEqual(result[4]["facts"]["age"], "41")
self.assertEqual(result[4]["facts"]["height_feet"], "6")
self.assertEqual(result[4]["facts"]["height_inches"], "2")
self.assertEqual(result[4]["wardrobe"], "weathered red flight jacket, grey cargo shorts, black boots")
self.assertEqual(len(result), 6)
def test_location_helper_matches_character_helper_shape_without_wardrobe(self):
node = self.image_nodes.DumasLocationHelperNode()
@@ -448,6 +459,7 @@ class DumasImageNodeTests(unittest.TestCase):
result = node.curate_prompt(
action_prompt="Dave runs from the Coffee Shop into the rain.",
anatomy_guard="auto",
subject_count_guard="auto",
anchor="grounded handheld thriller",
soundscape="steady rain",
ref_1=dave,
@@ -460,6 +472,8 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIn("<Picture 2> Coffee Shop", prompt)
self.assertIn("Action: Dave runs from the Coffee Shop into the rain.", prompt)
self.assertIn("Anatomy guard:", prompt)
self.assertIn("Subject count guard:", prompt)
self.assertIn("exactly one named character: <Picture 1> Dave", prompt)
self.assertIs(result[1], dave_image)
self.assertIs(result[2], cafe_image)
self.assertIsNone(result[3])
@@ -478,6 +492,7 @@ class DumasImageNodeTests(unittest.TestCase):
result = node.curate_prompt(
action_prompt="<Picture 1> Maya crosses to <ref3> as the wind rises.",
anatomy_guard="off",
subject_count_guard="off",
ref_1=first,
ref_2=second,
ref_3=third,
@@ -491,6 +506,65 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIsNone(result[3])
self.assertEqual(result[10], 2)
def test_h3_prompt_curator_can_force_subject_count_without_character_refs(self):
node = self.image_nodes.DumasH3PromptCuratorNode()
result = node.curate_prompt(
action_prompt="A locked-off shot of the empty corridor.",
anatomy_guard="off",
subject_count_guard="on",
)
self.assertIn("Subject count guard:", result[0])
self.assertIn("Only include the people explicitly described", result[0])
self.assertEqual(result[10], 0)
def test_h3_prompt_curator_treats_helper_image_pair_as_one_character(self):
helper = self.image_nodes.DumasCharacterHelperNode()
curator = self.image_nodes.DumasH3PromptCuratorNode()
image1 = FakeTensorBatch()
image2 = FakeTensorBatch()
helper_result = helper.build_character_text(
image1=image1,
image2=image2,
image1_picture_id="1",
image2_picture_id="2",
character_id="char_dave",
name="Dave",
alias="The Locksmith",
gender="male",
age="41",
nationality="English",
occupation="detective",
height_feet="6",
height_inches="2",
accent="English",
general="Tired eyes, cropped brown hair",
wardrobe="weathered red flight jacket",
)
result = curator.curate_prompt(
action_prompt="Dave checks the locked door.",
anatomy_guard="on",
subject_count_guard="auto",
ref_1=helper_result[4],
ref_2=helper_result[5],
)
self.assertIs(result[1], image1)
self.assertIs(result[2], image2)
self.assertEqual(result[10], 2)
self.assertIn("Character facts for <Picture 1> Dave", result[0])
self.assertIn("41 years old", result[0])
self.assertIn("6 foot 2 tall", result[0])
self.assertIn("exactly one named character: <Picture 1> Dave", result[0])
self.assertNotIn("exactly 2 named characters", result[0])
def test_h3_prompt_curator_defaults_anatomy_guard_to_on(self):
required = self.image_nodes.DumasH3PromptCuratorNode.INPUT_TYPES()["required"]
self.assertEqual(required["anatomy_guard"][1]["default"], "on")
def test_helper_node_mappings_use_general_purpose_helpers(self):
mappings = self.image_nodes.NODE_CLASS_MAPPINGS
display = self.image_nodes.NODE_DISPLAY_NAME_MAPPINGS