Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de62226f2 | ||
|
|
a51141cb26 | ||
|
|
dd8ef84379 | ||
|
|
d20b257134 | ||
|
|
173205ca51 | ||
|
|
98833ba99d | ||
|
|
a7f7225b3a |
@@ -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`, `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`
|
||||
- Inputs: `shot_seconds`, `fps`, optional `cap_to_h3_max`
|
||||
- Outputs: `seconds`, `frames`, `info`
|
||||
@@ -76,6 +83,18 @@
|
||||
- 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.
|
||||
|
||||
- `Dumas Character Helper`
|
||||
- Inputs: `image1`, `image2`, picture IDs, character identity fields, `general`, `wardrobe`
|
||||
- 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`
|
||||
- Outputs: `image1`, `image2`, `reference_prompt`, `reference1`, `reference2`
|
||||
- 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`
|
||||
- Inputs: `anchor_style`, `style_description`
|
||||
- Output: `anchor`
|
||||
@@ -83,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.
|
||||
- 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`
|
||||
@@ -239,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 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. 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.
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ LOGGER = logging.getLogger(__name__)
|
||||
MP_UNIT = 1024 * 1024
|
||||
RES_MULTIPLE = 32
|
||||
CUDA_MODEL_CHUNK_LENGTH = 17
|
||||
CUDA_MODEL_MIN_TOTAL_VRAM_BYTES = 10 * 1024 * 1024 * 1024
|
||||
_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
|
||||
|
||||
|
||||
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):
|
||||
if not _is_oom_error(exc):
|
||||
raise exc
|
||||
@@ -902,15 +939,22 @@ def upscale_latent_video(video, param):
|
||||
if mode == "off":
|
||||
return video, video.shape[-2], video.shape[-1]
|
||||
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")
|
||||
device = param.get("device", "cuda")
|
||||
precision = param.get("precision", "fp16")
|
||||
dev = torch.device(device if (device == "cpu" or torch.cuda.is_available()) else "cpu")
|
||||
try:
|
||||
with _hold_upscale_model_loaded():
|
||||
try:
|
||||
return _upscale_video_temporal_chunks(video, param, upscale_video_model)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+854
-6
@@ -27,6 +27,25 @@ _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 = ""
|
||||
_ANCHOR_STYLE_LEGACY_NOTE_RE = re.compile(
|
||||
r"\s*Keep this anchor focused on persistent camera language, lighting, "
|
||||
r"texture, environment treatment, and tone; do not name characters or "
|
||||
r"describe one-off actions\.?",
|
||||
re.I,
|
||||
)
|
||||
_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(
|
||||
[
|
||||
(
|
||||
@@ -346,6 +365,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": "",
|
||||
@@ -366,6 +426,11 @@ def _anchor_style_description(style_name):
|
||||
return _ANCHOR_STYLE_PRESETS.get(str(style_name or "").strip().lower(), "")
|
||||
|
||||
|
||||
def _clean_anchor_style_text(text):
|
||||
cleaned = _ANCHOR_STYLE_LEGACY_NOTE_RE.sub("", str(text or ""))
|
||||
return re.sub(r"[ \t]{2,}", " ", cleaned).strip()
|
||||
|
||||
|
||||
def _clean_input_token_value(value):
|
||||
cleaned = ""
|
||||
if value is not None:
|
||||
@@ -920,6 +985,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):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
@@ -1042,6 +1349,65 @@ def _build_character_wardrobe_text(wardrobe, character_id, name, alias):
|
||||
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:
|
||||
DESCRIPTION = (
|
||||
"Dumas Image Compare shows the difference between two images directly on "
|
||||
@@ -1604,6 +1970,322 @@ class DumasH3PlanExtractSceneImagesNode:
|
||||
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", _REFERENCE_TYPE, _REFERENCE_TYPE)
|
||||
RETURN_NAMES = ("image1", "image2", "reference_prompt", "wardrobe", "reference1", "reference2")
|
||||
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,
|
||||
)
|
||||
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:
|
||||
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", _REFERENCE_TYPE, _REFERENCE_TYPE)
|
||||
RETURN_NAMES = ("image1", "image2", "reference_prompt", "reference1", "reference2")
|
||||
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,
|
||||
)
|
||||
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:
|
||||
DESCRIPTION = (
|
||||
"Build one structured REFERENCE object for a character so H3 can carry "
|
||||
@@ -1836,6 +2518,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:
|
||||
DESCRIPTION = (
|
||||
"Choose an anchor-style preset, auto-fill its full description, and pass "
|
||||
@@ -1873,9 +2715,9 @@ class DumasAnchorStyleNode:
|
||||
}
|
||||
|
||||
def build_anchor(self, anchor_style, style_description):
|
||||
text = str(style_description or "").strip()
|
||||
text = _clean_anchor_style_text(style_description)
|
||||
if not text:
|
||||
text = _anchor_style_description(anchor_style)
|
||||
text = _clean_anchor_style_text(_anchor_style_description(anchor_style))
|
||||
return (text,)
|
||||
|
||||
|
||||
@@ -1887,9 +2729,12 @@ NODE_CLASS_MAPPINGS = {
|
||||
"DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode,
|
||||
"DumasCharacterReference": DumasCharacterReferenceNode,
|
||||
"DumasLocationReference": DumasLocationReferenceNode,
|
||||
"DumasSoundscapeHelper": DumasSoundscapeHelperNode,
|
||||
"DumasH3PromptCurator": DumasH3PromptCuratorNode,
|
||||
"DumasAnchorStyle": DumasAnchorStyleNode,
|
||||
"DumasCharacterHelper": DumasCharacterReferenceNode,
|
||||
"DumasH3CharacterHelper": DumasCharacterReferenceNode,
|
||||
"DumasCharacterHelper": DumasCharacterHelperNode,
|
||||
"DumasLocationHelper": DumasLocationHelperNode,
|
||||
"DumasH3CharacterHelper": DumasCharacterHelperNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -1900,7 +2745,10 @@ 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 Reference",
|
||||
"DumasH3CharacterHelper": "Dumas Character Reference",
|
||||
"DumasCharacterHelper": "Dumas Character Helper",
|
||||
"DumasLocationHelper": "Dumas Location Helper",
|
||||
"DumasH3CharacterHelper": "Dumas Character Helper",
|
||||
}
|
||||
|
||||
@@ -1414,6 +1414,103 @@ class DumasH3LongVideosHelperTests(unittest.TestCase):
|
||||
else:
|
||||
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):
|
||||
latent = importlib.import_module("dumas_h3_latent_upscale")
|
||||
|
||||
|
||||
@@ -356,6 +356,277 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
required = self.image_nodes.DumasLocationReferenceNode.INPUT_TYPES()["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")
|
||||
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()
|
||||
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.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):
|
||||
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()
|
||||
|
||||
@@ -417,6 +688,19 @@ class DumasImageNodeTests(unittest.TestCase):
|
||||
self.assertIn("real time", result[0])
|
||||
self.assertNotIn("persistent camera language", result[0])
|
||||
|
||||
def test_anchor_style_node_strips_legacy_persistent_anchor_note(self):
|
||||
node = self.image_nodes.DumasAnchorStyleNode()
|
||||
legacy = (
|
||||
"Gritty handheld realism. Keep this anchor focused on persistent camera "
|
||||
"language, lighting, texture, environment treatment, and tone; do not "
|
||||
"name characters or describe one-off actions."
|
||||
)
|
||||
|
||||
result = node.build_anchor("cinematic action movie", legacy)
|
||||
|
||||
self.assertEqual(result[0], "Gritty handheld realism.")
|
||||
self.assertNotIn("persistent camera language", result[0])
|
||||
|
||||
def test_anchor_style_node_prefers_manual_description_edits(self):
|
||||
node = self.image_nodes.DumasAnchorStyleNode()
|
||||
custom = "Lo-fi pirate broadcast with smeared highlights and anxious zoom corrections."
|
||||
|
||||
Reference in New Issue
Block a user