Compare commits

..
4 Commits
Author SHA1 Message Date
chris.dumas a51141cb26 Add location helper reference outputs 2026-09-07 07:46:24 +00:00
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
chris.dumas 173205ca51 Add H3 prompt curator 2026-09-05 16:43:05 +00:00
3 changed files with 740 additions and 12 deletions
+19 -5
View File
@@ -56,6 +56,13 @@
- Output: `prompt` - 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. - 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`, `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, 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` - `Dumas H3 Shot Length`
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max` - Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
- Outputs: `seconds`, `frames`, `info` - Outputs: `seconds`, `frames`, `info`
@@ -78,13 +85,15 @@
- `Dumas Character Helper` - `Dumas Character Helper`
- Inputs: `image1`, `image2`, picture IDs, character identity fields, `general`, `wardrobe` - Inputs: `image1`, `image2`, picture IDs, character identity fields, `general`, `wardrobe`
- Outputs: `image1`, `image2`, `reference_prompt`, `wardrobe` - Outputs: `image1`, `image2`, `reference_prompt`, `wardrobe`, `reference1`, `reference2`
- Restores the original general-purpose helper shape: pass two images through unchanged and emit prompt text/wardrobe text for manual wiring. - 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` - `Dumas Location Helper`
- Inputs: `image1`, `image2`, picture IDs, `location_id`, `name`, `alias`, `description`, `general` - Inputs: `image1`, `image2`, picture IDs, `location_id`, `name`, `alias`, `description`, `general`
- Outputs: `image1`, `image2`, `reference_prompt` - Outputs: `image1`, `image2`, `reference_prompt`, `reference1`, `reference2`
- Matching general-purpose helper for environments/locations: pass two images through unchanged and emit location reference prompt text. - Matching general-purpose helper for environments/locations: pass two images through unchanged, emit location reference prompt text, and provide two structured `REFERENCE` objects for the prompt curator.
- The structured references carry the same location name, alias, description, and notes, so mentioning the location name in `Dumas H3 Prompt Curator` can include both helper images and the location context automatically.
- `Dumas Anchor Style` - `Dumas Anchor Style`
- Inputs: `anchor_style`, `style_description` - Inputs: `anchor_style`, `style_description`
@@ -93,6 +102,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. - 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`. - 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` - `Dumas JSON String to Object`
- Input: `json_string` - Input: `json_string`
- Output: parsed `JSON` - Output: parsed `JSON`
@@ -249,7 +263,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 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. Both helpers also emit structured `REFERENCE` sockets for the curator. 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 `<Picture N>` 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. `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.
+514 -6
View File
@@ -27,6 +27,19 @@ _H3_PLAN_IMAGE_BINDINGS_CAP = 128
_H3_PLAN_IMAGE_SLOTS = 9 _H3_PLAN_IMAGE_SLOTS = 9
_FOLDER_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif") _FOLDER_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif", ".tiff", ".tif")
_ANCHOR_STYLE_H3_NOTE = "" _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."
)
_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( _ANCHOR_STYLE_PRESETS = OrderedDict(
[ [
( (
@@ -346,6 +359,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 = { _LOAD_IMAGES_FOLDER_DEFAULT_STATE = {
"version": 1, "version": 1,
"folder": "", "folder": "",
@@ -920,6 +974,248 @@ 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"<Picture {compacted}>"
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"<Picture {compact_picture_number}> {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 _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",
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)}
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)
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()
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}-><Picture {index}> {_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): def _parse_positive_int(value):
text = str(value or "").strip() text = str(value or "").strip()
if not text: if not text:
@@ -1669,8 +1965,8 @@ class DumasCharacterHelperNode:
"IMAGE sockets plus simple identity fields, while passing both images " "IMAGE sockets plus simple identity fields, while passing both images "
"through unchanged." "through unchanged."
) )
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING", "STRING") RETURN_TYPES = ("IMAGE", "IMAGE", "STRING", "STRING", _REFERENCE_TYPE, _REFERENCE_TYPE)
RETURN_NAMES = ("image1", "image2", "reference_prompt", "wardrobe") RETURN_NAMES = ("image1", "image2", "reference_prompt", "wardrobe", "reference1", "reference2")
FUNCTION = "build_character_text" FUNCTION = "build_character_text"
CATEGORY = "Dumas/MiniMax" CATEGORY = "Dumas/MiniMax"
@@ -1831,7 +2127,36 @@ class DumasCharacterHelperNode:
name, name,
alias, 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: class DumasLocationHelperNode:
@@ -1839,8 +2164,8 @@ class DumasLocationHelperNode:
"Build a general location reference prompt from two IMAGE sockets plus " "Build a general location reference prompt from two IMAGE sockets plus "
"simple environment fields, while passing both images through unchanged." "simple environment fields, while passing both images through unchanged."
) )
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING") RETURN_TYPES = ("IMAGE", "IMAGE", "STRING", _REFERENCE_TYPE, _REFERENCE_TYPE)
RETURN_NAMES = ("image1", "image2", "reference_prompt") RETURN_NAMES = ("image1", "image2", "reference_prompt", "reference1", "reference2")
FUNCTION = "build_location_text" FUNCTION = "build_location_text"
CATEGORY = "Dumas/MiniMax" CATEGORY = "Dumas/MiniMax"
@@ -1928,7 +2253,26 @@ class DumasLocationHelperNode:
description, description,
general, general,
) )
return (image1, image2, text) common = {
"kind": "location",
"explicit_id": location_id,
"name": name,
"aliases": alias,
"description": description,
"general": general,
"facts": {},
}
reference1 = make_reference(
image=image1,
summary="Primary location reference.",
**common,
)
reference2 = make_reference(
image=image2,
summary="Secondary location reference.",
**common,
)
return (image1, image2, text, reference1, reference2)
class DumasCharacterReferenceNode: class DumasCharacterReferenceNode:
@@ -2163,6 +2507,166 @@ 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 <Picture N>/<refN> "
"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"<Picture {slot}>/<ref{slot}> 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, <Picture N>, or <refN> to use it."
),
},
),
"anatomy_guard": (
["auto", "on", "off"],
{
"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,
}
def curate_prompt(
self,
action_prompt,
anatomy_guard,
subject_count_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,
subject_count_guard=subject_count_guard,
)
class DumasAnchorStyleNode: class DumasAnchorStyleNode:
DESCRIPTION = ( DESCRIPTION = (
"Choose an anchor-style preset, auto-fill its full description, and pass " "Choose an anchor-style preset, auto-fill its full description, and pass "
@@ -2214,6 +2718,8 @@ NODE_CLASS_MAPPINGS = {
"DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode, "DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode,
"DumasCharacterReference": DumasCharacterReferenceNode, "DumasCharacterReference": DumasCharacterReferenceNode,
"DumasLocationReference": DumasLocationReferenceNode, "DumasLocationReference": DumasLocationReferenceNode,
"DumasSoundscapeHelper": DumasSoundscapeHelperNode,
"DumasH3PromptCurator": DumasH3PromptCuratorNode,
"DumasAnchorStyle": DumasAnchorStyleNode, "DumasAnchorStyle": DumasAnchorStyleNode,
"DumasCharacterHelper": DumasCharacterHelperNode, "DumasCharacterHelper": DumasCharacterHelperNode,
"DumasLocationHelper": DumasLocationHelperNode, "DumasLocationHelper": DumasLocationHelperNode,
@@ -2228,6 +2734,8 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images", "DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images",
"DumasCharacterReference": "Dumas Character Reference", "DumasCharacterReference": "Dumas Character Reference",
"DumasLocationReference": "Dumas Location Reference", "DumasLocationReference": "Dumas Location Reference",
"DumasSoundscapeHelper": "Dumas Soundscape Helper",
"DumasH3PromptCurator": "Dumas H3 Prompt Curator",
"DumasAnchorStyle": "Dumas Anchor Style", "DumasAnchorStyle": "Dumas Anchor Style",
"DumasCharacterHelper": "Dumas Character Helper", "DumasCharacterHelper": "Dumas Character Helper",
"DumasLocationHelper": "Dumas Location Helper", "DumasLocationHelper": "Dumas Location Helper",
+207 -1
View File
@@ -386,6 +386,17 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIn("Dave is also known as The Locksmith", result[2]) self.assertIn("Dave is also known as The Locksmith", result[2])
self.assertIn("is 41 years old", 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.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): def test_location_helper_matches_character_helper_shape_without_wardrobe(self):
node = self.image_nodes.DumasLocationHelperNode() node = self.image_nodes.DumasLocationHelperNode()
@@ -410,7 +421,193 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIn("Coffee Shop is also known as Cafe Interior", result[2]) self.assertIn("Coffee Shop is also known as Cafe Interior", result[2])
self.assertIn("Warm tungsten lighting, narrow counter, rainy front window.", result[2]) self.assertIn("Warm tungsten lighting, narrow counter, rainy front window.", result[2])
self.assertIn("Evening ambience, cramped but cozy.", result[2]) self.assertIn("Evening ambience, cramped but cozy.", result[2])
self.assertEqual(len(result), 3) self.assertIs(result[3]["image"], image1)
self.assertIs(result[4]["image"], image2)
self.assertEqual(result[3]["kind"], "location")
self.assertEqual(result[4]["kind"], "location")
self.assertEqual(result[3]["id"], "coffee-shop-01")
self.assertEqual(result[4]["id"], "coffee-shop-01")
self.assertEqual(result[3]["name"], "Coffee Shop")
self.assertEqual(result[3]["aliases"], ["Cafe Interior"])
self.assertEqual(result[3]["description"], "Warm tungsten lighting, narrow counter, rainy front window")
self.assertEqual(result[3]["general"], "Evening ambience, cramped but cozy")
self.assertEqual(len(result), 5)
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",
subject_count_guard="auto",
anchor="grounded handheld thriller",
soundscape="steady rain",
ref_1=dave,
ref_2=van,
ref_3=cafe,
)
prompt = result[0]
self.assertIn("<Picture 1> Dave", prompt)
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])
self.assertEqual(result[10], 2)
self.assertIn("input 3-><Picture 2> 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="<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,
)
prompt = result[0]
self.assertIn("<Picture 1> Maya crosses to <Picture 2>", prompt)
self.assertNotIn("<Picture 3>", prompt)
self.assertIs(result[1], image1)
self.assertIs(result[2], image3)
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_uses_location_helper_references_by_name(self):
helper = self.image_nodes.DumasLocationHelperNode()
curator = self.image_nodes.DumasH3PromptCuratorNode()
image1 = FakeTensorBatch()
image2 = FakeTensorBatch()
helper_result = helper.build_location_text(
image1=image1,
image2=image2,
image1_picture_id="1",
image2_picture_id="2",
location_id="coffee_shop",
name="Coffee Shop",
alias="Cafe Interior",
description="Warm tungsten lighting, narrow counter, rainy front window",
general="Evening ambience, cramped but cozy",
)
result = curator.curate_prompt(
action_prompt="A slow push through the Coffee Shop as rain streaks the windows.",
anatomy_guard="on",
subject_count_guard="auto",
ref_1=helper_result[3],
ref_2=helper_result[4],
)
self.assertIs(result[1], image1)
self.assertIs(result[2], image2)
self.assertEqual(result[10], 2)
self.assertIn("<Picture 1> Coffee Shop", result[0])
self.assertIn("<Picture 2> Coffee Shop", result[0])
self.assertIn("Location context for <Picture 1> Coffee Shop", result[0])
self.assertIn("Warm tungsten lighting", result[0])
self.assertNotIn("Subject count guard:", 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): def test_helper_node_mappings_use_general_purpose_helpers(self):
mappings = self.image_nodes.NODE_CLASS_MAPPINGS mappings = self.image_nodes.NODE_CLASS_MAPPINGS
@@ -418,8 +615,17 @@ class DumasImageNodeTests(unittest.TestCase):
self.assertIs(mappings["DumasCharacterHelper"], self.image_nodes.DumasCharacterHelperNode) self.assertIs(mappings["DumasCharacterHelper"], self.image_nodes.DumasCharacterHelperNode)
self.assertIs(mappings["DumasLocationHelper"], self.image_nodes.DumasLocationHelperNode) 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["DumasCharacterHelper"], "Dumas Character Helper")
self.assertEqual(display["DumasLocationHelper"], "Dumas Location 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): def test_normalize_reference_upgrades_generic_summary_with_socket_picture_id(self):
image = FakeTensorBatch() image = FakeTensorBatch()