From d20b25713414baab8cf15135e372f8c2dd9b90f8 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Sat, 5 Sep 2026 17:54:43 +0000 Subject: [PATCH] Add H3 prompt subject count guard --- README.md | 4 +-- dumas_image_nodes.py | 57 +++++++++++++++++++++++++++++++-- tests/test_dumas_image_nodes.py | 22 +++++++++++++ 3 files changed, 79 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ec53acb..610298d 100644 --- a/README.md +++ b/README.md @@ -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, ``, or ``. 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` diff --git a/dumas_image_nodes.py b/dumas_image_nodes.py index 25a445a..7860513 100644 --- a/dumas_image_nodes.py +++ b/dumas_image_nodes.py @@ -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,46 @@ 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 = [] + 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")) + label = f"" + 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 +1188,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() @@ -2526,12 +2567,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 +2591,7 @@ class DumasH3PromptCuratorNode: self, action_prompt, anatomy_guard, + subject_count_guard, anchor="", soundscape="", ref_1=None, @@ -2558,6 +2610,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, ) diff --git a/tests/test_dumas_image_nodes.py b/tests/test_dumas_image_nodes.py index 5f58ca3..6bc1308 100644 --- a/tests/test_dumas_image_nodes.py +++ b/tests/test_dumas_image_nodes.py @@ -448,6 +448,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 +461,8 @@ class DumasImageNodeTests(unittest.TestCase): self.assertIn(" 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: Dave", prompt) self.assertIs(result[1], dave_image) self.assertIs(result[2], cafe_image) self.assertIsNone(result[3]) @@ -478,6 +481,7 @@ class DumasImageNodeTests(unittest.TestCase): result = node.curate_prompt( action_prompt=" Maya crosses to as the wind rises.", anatomy_guard="off", + subject_count_guard="off", ref_1=first, ref_2=second, ref_3=third, @@ -491,6 +495,24 @@ 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_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