Compare commits

..
2 Commits
5 changed files with 555 additions and 10 deletions
+11 -1
View File
@@ -76,6 +76,16 @@
- Output: `reference` - Output: `reference`
- Builds one structured `REFERENCE` object for a location/environment so H3 can use the same socket type for both character and scenic refs. - Builds one structured `REFERENCE` object for a location/environment so H3 can use the same socket type for both character and scenic refs.
- `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.
- `Dumas Location Helper`
- Inputs: `image1`, `image2`, picture IDs, `location_id`, `name`, `alias`, `description`, `general`
- Outputs: `image1`, `image2`, `reference_prompt`
- Matching general-purpose helper for environments/locations: pass two images through unchanged and emit location reference prompt text.
- `Dumas Anchor Style` - `Dumas Anchor Style`
- Inputs: `anchor_style`, `style_description` - Inputs: `anchor_style`, `style_description`
- Output: `anchor` - Output: `anchor`
@@ -239,7 +249,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 Reference` and `Dumas Location Reference` live in `Dumas/MiniMax`. Both output a structured `REFERENCE` object that carries the image plus its semantic payload. `Dumas H3 Long Videos` accepts those `REFERENCE` sockets directly on `ref_1`..`ref_9`, resolves `<Picture N>` against the wired slot positions, and can also pull character wardrobe context from the structured ref data when `character_memory` is left blank. `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 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.
+49 -5
View File
@@ -46,6 +46,7 @@ LOGGER = logging.getLogger(__name__)
MP_UNIT = 1024 * 1024 MP_UNIT = 1024 * 1024
RES_MULTIPLE = 32 RES_MULTIPLE = 32
CUDA_MODEL_CHUNK_LENGTH = 17 CUDA_MODEL_CHUNK_LENGTH = 17
CUDA_MODEL_MIN_TOTAL_VRAM_BYTES = 10 * 1024 * 1024 * 1024
_MODEL_HOLD_DEPTH = 0 _MODEL_HOLD_DEPTH = 0
@@ -76,6 +77,42 @@ def _should_retry_temporal_before_spatial(param):
return _uses_cuda_model_upscale(param) and int(param.get("chunk_length", 0) or 0) > CUDA_MODEL_CHUNK_LENGTH return _uses_cuda_model_upscale(param) and int(param.get("chunk_length", 0) or 0) > CUDA_MODEL_CHUNK_LENGTH
def _cuda_total_memory_bytes():
try:
if not torch.cuda.is_available():
return 0
if hasattr(torch.cuda, "mem_get_info"):
_free, total = torch.cuda.mem_get_info()
return int(total)
current_device = torch.cuda.current_device() if hasattr(torch.cuda, "current_device") else 0
props = torch.cuda.get_device_properties(current_device)
return int(getattr(props, "total_memory", 0) or 0)
except Exception:
return 0
def _should_skip_cuda_model_upscale(param):
total = _cuda_total_memory_bytes()
return _uses_cuda_model_upscale(param) and 0 < total < CUDA_MODEL_MIN_TOTAL_VRAM_BYTES
def _fallback_to_interp(video, param, reason):
method = param.get("method", "bilinear")
LOGGER.warning("H3 latent upscale model %s; using %s interpolation instead", reason, method)
try:
gc.collect()
except Exception:
pass
if torch.cuda.is_available():
try:
torch.cuda.empty_cache()
except Exception:
pass
interp_param = dict(param)
interp_param["mode"] = "interp"
return upscale_video_interp(video, interp_param)
def _retry_with_smaller_temporal(video, param, upscaler, exc): def _retry_with_smaller_temporal(video, param, upscaler, exc):
if not _is_oom_error(exc): if not _is_oom_error(exc):
raise exc raise exc
@@ -902,15 +939,22 @@ def upscale_latent_video(video, param):
if mode == "off": if mode == "off":
return video, video.shape[-2], video.shape[-1] return video, video.shape[-2], video.shape[-1]
if mode == "model": if mode == "model":
if _should_skip_cuda_model_upscale(param):
return _fallback_to_interp(video, param, "requires more than this card's VRAM")
model_name = param.get("model_name") model_name = param.get("model_name")
device = param.get("device", "cuda") device = param.get("device", "cuda")
precision = param.get("precision", "fp16") precision = param.get("precision", "fp16")
dev = torch.device(device if (device == "cpu" or torch.cuda.is_available()) else "cpu") dev = torch.device(device if (device == "cpu" or torch.cuda.is_available()) else "cpu")
with _hold_upscale_model_loaded(): try:
try: with _hold_upscale_model_loaded():
return _upscale_video_temporal_chunks(video, param, upscale_video_model) try:
finally: return _upscale_video_temporal_chunks(video, param, upscale_video_model)
_unload_upscale_model_now(model_name, dev, precision) finally:
_unload_upscale_model_now(model_name, dev, precision)
except RuntimeError as exc:
if not _is_oom_error(exc):
raise
return _fallback_to_interp(video, param, "exhausted GPU memory")
return _upscale_video_temporal_chunks(video, param, upscale_video_interp) return _upscale_video_temporal_chunks(video, param, upscale_video_interp)
+333 -4
View File
@@ -1042,6 +1042,65 @@ def _build_character_wardrobe_text(wardrobe, character_id, name, alias):
return text return text
def _label_for_location(name, location_id):
return _normalize_free_text(name) or _normalize_free_text(location_id) or "the location"
def _build_location_helper_text(
primary_picture_id,
secondary_picture_id,
location_id,
name,
alias,
description,
general,
):
primary_picture = int(primary_picture_id)
secondary_picture = int(secondary_picture_id)
location_name = _normalize_free_text(name)
location_id = _normalize_free_text(location_id)
alias = _normalize_free_text(alias)
description = _ensure_sentence(description)
general = _ensure_sentence(general)
location_label = _label_for_location(location_name, location_id)
if location_name:
first_line = (
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
f"the same location called {location_name}."
)
elif location_id:
first_line = (
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
f'the same location with ID "{location_id}".'
)
else:
first_line = (
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
"the same location."
)
lines = [
first_line,
f"<Picture {primary_picture}> is the primary wide/environment reference for {location_label}.",
f"<Picture {secondary_picture}> is the secondary detail/angle reference for {location_label}.",
]
facts = []
if alias:
facts.append(f"is also known as {alias}")
if description:
facts.append(description)
if facts:
lines.append(f"{location_label} {', '.join(facts)}")
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 "
@@ -1604,6 +1663,274 @@ class DumasH3PlanExtractSceneImagesNode:
return (passthrough_plan, *images, _connected_image_count(images)) return (passthrough_plan, *images, _connected_image_count(images))
class DumasCharacterHelperNode:
DESCRIPTION = (
"Build a general character reference prompt and wardrobe sheet from two "
"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")
FUNCTION = "build_character_text"
CATEGORY = "Dumas/MiniMax"
@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": (
["1", "2", "3", "4", "5", "6", "7", "8", "9"],
{
"default": "1",
"tooltip": "Picture number to mention for image1 in the reference prompt.",
},
),
"image2_picture_id": (
["1", "2", "3", "4", "5", "6", "7", "8", "9"],
{
"default": "2",
"tooltip": "Picture number to mention for image2 in the reference prompt.",
},
),
"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.",
},
),
"alias": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional alternate name, codename, or nickname.",
},
),
"gender": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional gender field for non-visual character facts.",
},
),
"age": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional numeric age. Invalid values are omitted.",
},
),
"nationality": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional nationality, origin, or cultural background.",
},
),
"occupation": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional job, role, or function that is not visually obvious.",
},
),
"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 non-clothing details appended as the last sentence of the reference prompt.",
},
),
"wardrobe": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional wardrobe/channel text. Plain clothing lists are auto-wrapped as 'Name = ...' when a name, alias, or character ID is present.",
},
),
}
}
def build_character_text(
self,
image1,
image2,
image1_picture_id,
image2_picture_id,
character_id,
name,
alias,
gender,
age,
nationality,
occupation,
height_feet,
height_inches,
accent,
general,
wardrobe,
):
text = _build_character_helper_text(
image1_picture_id,
image2_picture_id,
character_id,
name,
alias,
gender,
age,
nationality,
occupation,
height_feet,
height_inches,
accent,
general,
)
wardrobe_text = _build_character_wardrobe_text(
wardrobe,
character_id,
name,
alias,
)
return (image1, image2, text, wardrobe_text)
class DumasLocationHelperNode:
DESCRIPTION = (
"Build a general location reference prompt from two IMAGE sockets plus "
"simple environment fields, while passing both images through unchanged."
)
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING")
RETURN_NAMES = ("image1", "image2", "reference_prompt")
FUNCTION = "build_location_text"
CATEGORY = "Dumas/MiniMax"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image1": ("IMAGE", {"tooltip": "Primary location image to pass through and describe."}),
"image2": ("IMAGE", {"tooltip": "Secondary location image to pass through and describe."}),
"image1_picture_id": (
["1", "2", "3", "4", "5", "6", "7", "8", "9"],
{
"default": "1",
"tooltip": "Picture number to mention for image1 in the reference prompt.",
},
),
"image2_picture_id": (
["1", "2", "3", "4", "5", "6", "7", "8", "9"],
{
"default": "2",
"tooltip": "Picture number to mention for image2 in the reference prompt.",
},
),
"location_id": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional location ID string to include in the output text.",
},
),
"name": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Location name used in the main reference sentences.",
},
),
"alias": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional alternate name, label, or area name.",
},
),
"description": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Persistent environment, layout, and atmosphere description.",
},
),
"general": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional extra notes appended as the last sentence of the reference prompt.",
},
),
}
}
def build_location_text(
self,
image1,
image2,
image1_picture_id,
image2_picture_id,
location_id,
name,
alias,
description,
general,
):
text = _build_location_helper_text(
image1_picture_id,
image2_picture_id,
location_id,
name,
alias,
description,
general,
)
return (image1, image2, text)
class DumasCharacterReferenceNode: class DumasCharacterReferenceNode:
DESCRIPTION = ( DESCRIPTION = (
"Build one structured REFERENCE object for a character so H3 can carry " "Build one structured REFERENCE object for a character so H3 can carry "
@@ -1888,8 +2215,9 @@ NODE_CLASS_MAPPINGS = {
"DumasCharacterReference": DumasCharacterReferenceNode, "DumasCharacterReference": DumasCharacterReferenceNode,
"DumasLocationReference": DumasLocationReferenceNode, "DumasLocationReference": DumasLocationReferenceNode,
"DumasAnchorStyle": DumasAnchorStyleNode, "DumasAnchorStyle": DumasAnchorStyleNode,
"DumasCharacterHelper": DumasCharacterReferenceNode, "DumasCharacterHelper": DumasCharacterHelperNode,
"DumasH3CharacterHelper": DumasCharacterReferenceNode, "DumasLocationHelper": DumasLocationHelperNode,
"DumasH3CharacterHelper": DumasCharacterHelperNode,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
@@ -1901,6 +2229,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"DumasCharacterReference": "Dumas Character Reference", "DumasCharacterReference": "Dumas Character Reference",
"DumasLocationReference": "Dumas Location Reference", "DumasLocationReference": "Dumas Location Reference",
"DumasAnchorStyle": "Dumas Anchor Style", "DumasAnchorStyle": "Dumas Anchor Style",
"DumasCharacterHelper": "Dumas Character Reference", "DumasCharacterHelper": "Dumas Character Helper",
"DumasH3CharacterHelper": "Dumas Character Reference", "DumasLocationHelper": "Dumas Location Helper",
"DumasH3CharacterHelper": "Dumas Character Helper",
} }
+97
View File
@@ -1414,6 +1414,103 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
else: else:
latent.torch.device = original_device latent.torch.device = original_device
def test_model_upscale_oom_falls_back_to_interp(self):
latent = importlib.import_module("dumas_h3_latent_upscale")
calls = []
original_temporal = latent._upscale_video_temporal_chunks
original_interp = latent.upscale_video_interp
original_unload_now = latent._unload_upscale_model_now
original_cuda = latent.torch.cuda
original_device = getattr(latent.torch, "device", None)
try:
latent.torch.cuda = types.SimpleNamespace(
is_available=lambda: True,
empty_cache=lambda: calls.append(("empty_cache",)),
)
latent.torch.device = lambda value: value
def temporal(_video, _param, _upscaler):
calls.append(("temporal", latent._MODEL_HOLD_DEPTH))
raise RuntimeError("H3 latent upscale exhausted its GPU spatial fallbacks")
def interp(video, param):
calls.append(("interp", video, param.get("mode"), param.get("method")))
return "interp_video", 8, 16
def unload_now(name, device, precision):
calls.append(("unload", name, device, precision, latent._MODEL_HOLD_DEPTH))
latent._upscale_video_temporal_chunks = temporal
latent.upscale_video_interp = interp
latent._unload_upscale_model_now = unload_now
result = latent.upscale_latent_video("source", {
"mode": "model",
"model_name": "upscale.safetensors",
"method": "bilinear",
"device": "cuda",
"precision": "fp16",
})
self.assertEqual(result, ("interp_video", 8, 16))
self.assertEqual(calls[0], ("temporal", 1))
self.assertEqual(calls[1], ("unload", "upscale.safetensors", "cuda", "fp16", 1))
self.assertIn(("empty_cache",), calls)
self.assertEqual(calls[-1], ("interp", "source", "interp", "bilinear"))
self.assertEqual(latent._MODEL_HOLD_DEPTH, 0)
finally:
latent._upscale_video_temporal_chunks = original_temporal
latent.upscale_video_interp = original_interp
latent._unload_upscale_model_now = original_unload_now
latent.torch.cuda = original_cuda
if original_device is None:
delattr(latent.torch, "device")
else:
latent.torch.device = original_device
def test_model_upscale_skips_learned_model_on_8gb_cuda(self):
latent = importlib.import_module("dumas_h3_latent_upscale")
calls = []
original_temporal = latent._upscale_video_temporal_chunks
original_interp = latent.upscale_video_interp
original_cuda = latent.torch.cuda
try:
latent.torch.cuda = types.SimpleNamespace(
is_available=lambda: True,
mem_get_info=lambda: (1 * 1024 * 1024 * 1024, 8 * 1024 * 1024 * 1024),
empty_cache=lambda: calls.append(("empty_cache",)),
)
def temporal(_video, _param, _upscaler):
calls.append(("temporal",))
raise AssertionError("learned model path should be skipped on 8GB CUDA")
def interp(video, param):
calls.append(("interp", video, param.get("mode"), param.get("method")))
return "interp_video", 8, 16
latent._upscale_video_temporal_chunks = temporal
latent.upscale_video_interp = interp
result = latent.upscale_latent_video("source", {
"mode": "model",
"model_name": "upscale.safetensors",
"method": "bilinear",
"device": "cuda",
"precision": "fp16",
})
self.assertEqual(result, ("interp_video", 8, 16))
self.assertNotIn(("temporal",), calls)
self.assertIn(("empty_cache",), calls)
self.assertEqual(calls[-1], ("interp", "source", "interp", "bilinear"))
finally:
latent._upscale_video_temporal_chunks = original_temporal
latent.upscale_video_interp = original_interp
latent.torch.cuda = original_cuda
def test_upscale_video_model_raises_when_gpu_cannot_shrink(self): def test_upscale_video_model_raises_when_gpu_cannot_shrink(self):
latent = importlib.import_module("dumas_h3_latent_upscale") latent = importlib.import_module("dumas_h3_latent_upscale")
+65
View File
@@ -356,6 +356,71 @@ class DumasImageNodeTests(unittest.TestCase):
required = self.image_nodes.DumasLocationReferenceNode.INPUT_TYPES()["required"] required = self.image_nodes.DumasLocationReferenceNode.INPUT_TYPES()["required"]
self.assertNotIn("picture_id", required) self.assertNotIn("picture_id", required)
def test_character_helper_restores_image_and_text_outputs(self):
node = self.image_nodes.DumasCharacterHelperNode()
image1 = FakeTensorBatch()
image2 = FakeTensorBatch()
result = node.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="a detective",
height_feet="6",
height_inches="2",
accent="English",
general="Moves carefully and notices every exit",
wardrobe="weathered red flight jacket, grey cargo shorts, black boots",
)
self.assertIs(result[0], image1)
self.assertIs(result[1], image2)
self.assertIn("<Picture 1> and <Picture 2> reference the same character", result[2])
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")
def test_location_helper_matches_character_helper_shape_without_wardrobe(self):
node = self.image_nodes.DumasLocationHelperNode()
image1 = FakeTensorBatch()
image2 = FakeTensorBatch()
result = node.build_location_text(
image1=image1,
image2=image2,
image1_picture_id="3",
image2_picture_id="4",
location_id="coffee-shop-01",
name="Coffee Shop",
alias="Cafe Interior",
description="Warm tungsten lighting, narrow counter, rainy front window",
general="Evening ambience, cramped but cozy",
)
self.assertIs(result[0], image1)
self.assertIs(result[1], image2)
self.assertIn("<Picture 3> and <Picture 4> reference the same location", 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("Evening ambience, cramped but cozy.", result[2])
self.assertEqual(len(result), 3)
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.assertEqual(display["DumasCharacterHelper"], "Dumas Character Helper")
self.assertEqual(display["DumasLocationHelper"], "Dumas Location Helper")
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()