From 173205ca51963a9906291659dc630883e01b2460 Mon Sep 17 00:00:00 2001 From: Chris Dumas Date: Sat, 5 Sep 2026 16:43:05 +0000 Subject: [PATCH] Add H3 prompt curator --- README.md | 14 +- dumas_image_nodes.py | 402 ++++++++++++++++++++++++++++++++ tests/test_dumas_image_nodes.py | 88 +++++++ 3 files changed, 503 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fa6be5f..ec53acb 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,13 @@ - Output: `prompt` - 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` + - 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. + - `Dumas H3 Shot Length` - Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max` - Outputs: `seconds`, `frames`, `info` @@ -93,6 +100,11 @@ - The preset wording is tuned for H3-safe persistent anchors: camera language, lighting, texture, production treatment, and tone, without naming characters or describing one-off actions. - Selecting a preset fills the editable description field, and the edited multiline description is the `STRING` value passed downstream into H3 anchor sockets such as `anchor_override`. +- `Dumas Soundscape Helper` + - Inputs: `soundscape`, `soundscape_description` + - Output: `soundscape` + - Matching soundscape helper for standalone H3 prompts. Pick a preset such as quiet interior, rainy street, cafe, city night, forest, industrial, or silent, then edit the text that flows into `Dumas H3 Prompt Curator`. + - `Dumas JSON String to Object` - Input: `json_string` - Output: parsed `JSON` @@ -249,7 +261,7 @@ 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` and the local `ref2v` lane. The upstream H3 plan node cannot dynamically grow nine 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 nine IMAGE sockets per scene through chained helper nodes. -`Dumas Character Helper` is the restored two-image/text helper for general H3 workflows, and `Dumas Location Helper` mirrors it for scene/environment references. The structured `Dumas Character Reference` and `Dumas Location Reference` nodes remain available separately for workflows that still want a single `REFERENCE` socket. +`Dumas Character Helper` is the restored two-image/text helper for general H3 workflows, and `Dumas Location Helper` mirrors it for scene/environment references. The structured `Dumas Character Reference` and `Dumas Location Reference` nodes remain available separately for workflows that want a single `REFERENCE` socket. `Dumas H3 Prompt Curator` consumes those structured references, assigns the final `` numbering, and outputs only the compacted images the prompt actually mentions. `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. diff --git a/dumas_image_nodes.py b/dumas_image_nodes.py index 7cb87f0..25a445a 100644 --- a/dumas_image_nodes.py +++ b/dumas_image_nodes.py @@ -27,6 +27,15 @@ _H3_PLAN_IMAGE_BINDINGS_CAP = 128 _H3_PLAN_IMAGE_SLOTS = 9 _FOLDER_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif") _ANCHOR_STYLE_H3_NOTE = "" +_H3_PROMPT_REF_SLOTS = 9 +_H3_PROMPT_MAX_CHARS = 7000 +_PICTURE_TAG_RE = re.compile(r"<\s*picture[\s_\-]*(\d+)\s*>", re.I) +_REF_TAG_RE = re.compile(r"<\s*ref[\s_\-]*(\d+)\s*>", re.I) +_ANATOMY_GUARD_TEXT = ( + "Each person has one head, two arms, two hands with five fingers on each hand, " + "and two legs with two feet. Limbs stay attached to the correct body and move " + "only with the person they belong to." +) _ANCHOR_STYLE_PRESETS = OrderedDict( [ ( @@ -346,6 +355,47 @@ _ANCHOR_STYLE_PRESETS = OrderedDict( ), ] ) +_SOUNDSCAPE_PRESETS = OrderedDict( + [ + ( + "quiet interior", + "quiet indoor room tone, faint ventilation and distant household ambience", + ), + ( + "rainy street", + "steady rain, wet pavement, distant traffic hum", + ), + ( + "cafe", + "low room tone, faint glassware, cutlery, and muted conversation", + ), + ( + "city night", + "distant traffic hum, occasional horn, night air", + ), + ( + "forest", + "wind in leaves, distant birds, soft natural ambience", + ), + ( + "industrial", + "large interior reverb, distant metal ticks, low machine hum", + ), + ( + "silent", + "no dialogue, no vocals, only the natural ambient bed of the scene", + ), + ("custom", ""), + ] +) + + +def _soundscape_options(): + return list(_SOUNDSCAPE_PRESETS.keys()) + + +def _soundscape_description(soundscape_name): + return _SOUNDSCAPE_PRESETS.get(soundscape_name, "") _LOAD_IMAGES_FOLDER_DEFAULT_STATE = { "version": 1, "folder": "", @@ -920,6 +970,206 @@ def normalize_reference(value, picture_id=None, allow_image_fallback=True): ) +def _reference_text(value): + return " ".join(str(value or "").split()).strip() + + +def _reference_sentence(value): + text = _reference_text(value) + if text and text[-1] not in ".!?": + text += "." + return text + + +def _reference_name_keys(ref): + names = [] + for key in ("name", "id"): + value = _reference_text(ref.get(key)) + if value: + names.append(value) + for alias in ref.get("aliases") or []: + value = _reference_text(alias) + if value: + names.append(value) + seen = set() + out = [] + for name in names: + key = name.lower() + if key in seen: + continue + seen.add(key) + out.append(name) + return out + + +def _reference_image(ref): + if not isinstance(ref, dict): + return None + return ref.get("image") + + +def _normalize_prompt_refs(raw_refs): + refs = [] + for slot_number, raw in enumerate(raw_refs or (), 1): + if raw is None: + refs.append(None) + continue + try: + ref = normalize_reference(raw, picture_id=slot_number, allow_image_fallback=False) + except Exception: + refs.append(None) + continue + if _reference_image(ref) is None: + refs.append(None) + else: + refs.append(ref) + return refs + + +def _explicit_reference_tags(text): + return sorted( + { + int(match.group(1)) + for pattern in (_PICTURE_TAG_RE, _REF_TAG_RE) + for match in pattern.finditer(text or "") + } + ) + + +def _name_matches_reference(text, ref): + haystack = str(text or "") + for name in _reference_name_keys(ref): + if re.search(r"\b" + re.escape(name) + r"\b", haystack, re.I): + return True + return False + + +def _selected_prompt_refs(action_prompt, refs): + selected = [] + seen_slots = set() + for slot_number in _explicit_reference_tags(action_prompt): + if not (1 <= slot_number <= len(refs)): + continue + ref = refs[slot_number - 1] + if ref is None: + continue + selected.append((slot_number, ref)) + seen_slots.add(slot_number) + for slot_number, ref in enumerate(refs, 1): + if slot_number in seen_slots or ref is None: + continue + if _name_matches_reference(action_prompt, ref): + selected.append((slot_number, ref)) + seen_slots.add(slot_number) + return selected + + +def _replace_reference_tags(text, picture_map): + def repl(match): + original = int(match.group(1)) + compacted = picture_map.get(original) + if compacted is None: + return "" + return f"" + + rewritten = _PICTURE_TAG_RE.sub(repl, str(text or "")) + rewritten = _REF_TAG_RE.sub(repl, rewritten) + return re.sub(r"[ \t]{2,}", " ", rewritten).strip() + + +def _reference_fact_sentence(ref, label): + if ref.get("kind") != "character": + return "" + facts = dict(ref.get("facts") or {}) + bits = [] + aliases = [_reference_text(alias) for alias in (ref.get("aliases") or []) if _reference_text(alias)] + if aliases: + bits.append(f"also known as {aliases[0]}") + for key in ("gender", "nationality", "occupation"): + value = _reference_text(facts.get(key)) + if value: + bits.append(value if key != "occupation" else f"works as {value}") + age = _parse_positive_int(facts.get("age")) + if age is not None: + bits.append(f"{age} years old") + feet = _reference_text(facts.get("height_feet")) + inches = _reference_text(facts.get("height_inches")) + if feet and inches: + bits.append(f"{feet} foot {inches} tall") + elif feet: + bits.append(f"{feet} foot tall") + accent = _reference_text(facts.get("accent")) + if accent: + bits.append(f"speaks with a {accent} accent") + if not bits: + return "" + return f"Character facts for {label}: " + ", ".join(bits) + "." + + +def _reference_context(ref, compact_picture_number): + label_name = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or "this reference" + label = f" {label_name}" + parts = [_reference_sentence(_reference_summary(ref.get("kind"), label_name, compact_picture_number))] + description = _reference_sentence(ref.get("description")) + wardrobe = _reference_sentence(ref.get("wardrobe")) + general = _reference_sentence(ref.get("general")) + facts = _reference_fact_sentence(ref, label) + if ref.get("kind") == "location": + if description: + parts.append(f"Location context for {label}: {description}") + if general: + parts.append(f"Location notes for {label}: {general}") + else: + if facts: + parts.append(facts) + if description: + parts.append(f"Persistent appearance for {label}: {description}") + if wardrobe: + parts.append(f"Persistent wardrobe/style for {label}: {wardrobe}") + if general: + parts.append(f"Character notes for {label}: {general}") + return " ".join(part for part in parts if part).strip() + + +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"): + 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)} + action = _replace_reference_tags(action_prompt, picture_map) + + prompt_parts = [] + _append_prompt_section(prompt_parts, "Scene anchor", anchor) + if selected: + contexts = [_reference_context(ref, picture_number) for picture_number, (_slot, ref) in enumerate(selected, 1)] + _append_prompt_section(prompt_parts, "Reference context", " ".join(contexts)) + _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) + _append_prompt_section(prompt_parts, "overall_soundscape", soundscape) + + prompt = "\n\n".join(prompt_parts).strip() + if len(prompt) > _H3_PROMPT_MAX_CHARS: + prompt = prompt[: _H3_PROMPT_MAX_CHARS - 3].rstrip() + "..." + images = [_reference_image(ref) for _slot, ref in selected] + images.extend([None] * (_H3_PROMPT_REF_SLOTS - len(images))) + debug = ( + f"Selected {len(selected)} reference(s): " + + ", ".join( + f"input {slot}-> {_reference_text(ref.get('name')) or ref.get('id')}" + for index, (slot, ref) in enumerate(selected, 1) + ) + if selected + else "Selected 0 references." + ) + return (prompt, *images[:_H3_PROMPT_REF_SLOTS], len(selected), debug) + + def _parse_positive_int(value): text = str(value or "").strip() if not text: @@ -2163,6 +2413,154 @@ class DumasLocationReferenceNode: ) +class DumasSoundscapeHelperNode: + DESCRIPTION = ( + "Choose a soundscape preset, auto-fill its editable description, and pass " + "the final soundscape text downstream for MiniMax H3 prompts." + ) + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("soundscape",) + FUNCTION = "build_soundscape" + CATEGORY = "Dumas/MiniMax" + + @classmethod + def INPUT_TYPES(cls): + default_soundscape = "quiet interior" + return { + "required": { + "soundscape": ( + _soundscape_options(), + { + "default": default_soundscape, + "tooltip": "Preset title used to seed the editable soundscape description.", + }, + ), + "soundscape_description": ( + "STRING", + { + "default": _soundscape_description(default_soundscape), + "multiline": True, + "tooltip": ( + "Editable environmental audio description. Whatever text is here " + "is what the node outputs to the soundscape socket." + ), + }, + ), + } + } + + def build_soundscape(self, soundscape, soundscape_description): + text = str(soundscape_description or "").strip() + if not text: + text = _soundscape_description(soundscape) + return (text,) + + +class DumasH3PromptCuratorNode: + DESCRIPTION = ( + "Curate one MiniMax H3 prompt from an action textbox, anchor text, " + "soundscape text, and up to nine structured references. References are " + "compacted so only mentioned names, aliases, or explicit / " + "tags are sent onward." + ) + RETURN_TYPES = ("STRING",) + ("IMAGE",) * _H3_PROMPT_REF_SLOTS + ("INT", "STRING") + RETURN_NAMES = ( + "prompt", + "ref_image_1", + "ref_image_2", + "ref_image_3", + "ref_image_4", + "ref_image_5", + "ref_image_6", + "ref_image_7", + "ref_image_8", + "ref_image_9", + "reference_count", + "debug", + ) + FUNCTION = "curate_prompt" + CATEGORY = "Dumas/MiniMax" + + @classmethod + def INPUT_TYPES(cls): + optional = { + "anchor": ( + "STRING", + { + "forceInput": True, + "tooltip": "Optional anchor/style text, usually from Dumas Anchor Style.", + }, + ), + "soundscape": ( + "STRING", + { + "forceInput": True, + "tooltip": "Optional soundscape text, usually from Dumas Soundscape Helper.", + }, + ), + } + for slot in range(1, _H3_PROMPT_REF_SLOTS + 1): + optional[f"ref_{slot}"] = ( + _REFERENCE_TYPE, + { + "tooltip": ( + f"Optional structured reference {slot}. The curator only outputs " + "it if the action prompt mentions its name/alias or an explicit " + f"/ tag." + ) + }, + ) + return { + "required": { + "action_prompt": ( + "STRING", + { + "default": "", + "multiline": True, + "tooltip": ( + "Write the final shot action here using character/location names. " + "Mention a reference by name, alias, , or to use it." + ), + }, + ), + "anatomy_guard": ( + ["auto", "on", "off"], + { + "default": "auto", + "tooltip": ( + "Add the anatomy guard. Auto adds it when a character reference is used." + ), + }, + ), + }, + "optional": optional, + } + + def curate_prompt( + self, + action_prompt, + anatomy_guard, + anchor="", + soundscape="", + ref_1=None, + ref_2=None, + ref_3=None, + ref_4=None, + ref_5=None, + ref_6=None, + ref_7=None, + ref_8=None, + ref_9=None, + ): + return curate_h3_prompt( + action_prompt, + anchor=anchor, + 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, + ) + + class DumasAnchorStyleNode: DESCRIPTION = ( "Choose an anchor-style preset, auto-fill its full description, and pass " @@ -2214,6 +2612,8 @@ NODE_CLASS_MAPPINGS = { "DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode, "DumasCharacterReference": DumasCharacterReferenceNode, "DumasLocationReference": DumasLocationReferenceNode, + "DumasSoundscapeHelper": DumasSoundscapeHelperNode, + "DumasH3PromptCurator": DumasH3PromptCuratorNode, "DumasAnchorStyle": DumasAnchorStyleNode, "DumasCharacterHelper": DumasCharacterHelperNode, "DumasLocationHelper": DumasLocationHelperNode, @@ -2228,6 +2628,8 @@ NODE_DISPLAY_NAME_MAPPINGS = { "DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images", "DumasCharacterReference": "Dumas Character Reference", "DumasLocationReference": "Dumas Location Reference", + "DumasSoundscapeHelper": "Dumas Soundscape Helper", + "DumasH3PromptCurator": "Dumas H3 Prompt Curator", "DumasAnchorStyle": "Dumas Anchor Style", "DumasCharacterHelper": "Dumas Character Helper", "DumasLocationHelper": "Dumas Location Helper", diff --git a/tests/test_dumas_image_nodes.py b/tests/test_dumas_image_nodes.py index c10b9fe..5f58ca3 100644 --- a/tests/test_dumas_image_nodes.py +++ b/tests/test_dumas_image_nodes.py @@ -412,14 +412,102 @@ class DumasImageNodeTests(unittest.TestCase): self.assertIn("Evening ambience, cramped but cozy.", result[2]) self.assertEqual(len(result), 3) + def test_soundscape_helper_defaults_to_selected_preset_description(self): + node = self.image_nodes.DumasSoundscapeHelperNode() + + result = node.build_soundscape("rainy street", "") + + self.assertEqual(result[0], "steady rain, wet pavement, distant traffic hum") + + def test_h3_prompt_curator_compacts_named_references(self): + node = self.image_nodes.DumasH3PromptCuratorNode() + dave_image = FakeTensorBatch() + cafe_image = FakeTensorBatch() + van_image = FakeTensorBatch() + dave = self.image_nodes.make_reference( + kind="character", + image=dave_image, + name="Dave", + aliases="The Locksmith", + description="tired eyes, cropped brown hair", + wardrobe="red flight jacket", + ) + cafe = self.image_nodes.make_reference( + kind="location", + image=cafe_image, + name="Coffee Shop", + description="warm tungsten lighting and rainy windows", + ) + van = self.image_nodes.make_reference( + kind="location", + image=van_image, + name="Blue Van", + description="scuffed blue delivery van", + ) + + result = node.curate_prompt( + action_prompt="Dave runs from the Coffee Shop into the rain.", + anatomy_guard="auto", + anchor="grounded handheld thriller", + soundscape="steady rain", + ref_1=dave, + ref_2=van, + ref_3=cafe, + ) + + prompt = result[0] + self.assertIn(" Dave", prompt) + self.assertIn(" Coffee Shop", prompt) + self.assertIn("Action: Dave runs from the Coffee Shop into the rain.", prompt) + self.assertIn("Anatomy guard:", prompt) + self.assertIs(result[1], dave_image) + self.assertIs(result[2], cafe_image) + self.assertIsNone(result[3]) + self.assertEqual(result[10], 2) + self.assertIn("input 3-> Coffee Shop", result[11]) + + def test_h3_prompt_curator_renumbers_explicit_reference_tags(self): + node = self.image_nodes.DumasH3PromptCuratorNode() + image1 = FakeTensorBatch() + image3 = FakeTensorBatch() + unused = FakeTensorBatch() + first = self.image_nodes.make_reference(kind="character", image=image1, name="Maya") + second = self.image_nodes.make_reference(kind="location", image=unused, name="Lobby") + third = self.image_nodes.make_reference(kind="location", image=image3, name="Rooftop") + + result = node.curate_prompt( + action_prompt=" Maya crosses to as the wind rises.", + anatomy_guard="off", + ref_1=first, + ref_2=second, + ref_3=third, + ) + + prompt = result[0] + self.assertIn(" Maya crosses to ", prompt) + self.assertNotIn("", prompt) + self.assertIs(result[1], image1) + self.assertIs(result[2], image3) + self.assertIsNone(result[3]) + self.assertEqual(result[10], 2) + 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 self.assertIs(mappings["DumasCharacterHelper"], self.image_nodes.DumasCharacterHelperNode) self.assertIs(mappings["DumasLocationHelper"], self.image_nodes.DumasLocationHelperNode) + self.assertIs(mappings["DumasSoundscapeHelper"], self.image_nodes.DumasSoundscapeHelperNode) + self.assertIs(mappings["DumasH3PromptCurator"], self.image_nodes.DumasH3PromptCuratorNode) self.assertEqual(display["DumasCharacterHelper"], "Dumas Character Helper") self.assertEqual(display["DumasLocationHelper"], "Dumas Location Helper") + self.assertEqual(display["DumasSoundscapeHelper"], "Dumas Soundscape Helper") + self.assertEqual(display["DumasH3PromptCurator"], "Dumas H3 Prompt Curator") + + def test_h3_prompt_curator_uses_documented_reference_limits(self): + node = self.image_nodes.DumasH3PromptCuratorNode() + self.assertEqual(len(node.RETURN_TYPES), 12) + self.assertEqual(node.RETURN_NAMES[1:10], tuple(f"ref_image_{i}" for i in range(1, 10))) def test_normalize_reference_upgrades_generic_summary_with_socket_picture_id(self): image = FakeTensorBatch()