Files
DumasNodes/dumas_image_nodes.py
T

2864 lines
105 KiB
Python

import hashlib
import json
import os
import random
import re
import time
import uuid
from collections import OrderedDict
import numpy as np
from PIL import Image
import folder_paths
_MEDIA_EXT_RE = re.compile(
r"\.(png|jpe?g|webp|gif|bmp|tiff?|avif|mp4|mov|webm|mkv|m4v)$", re.IGNORECASE
)
_DATE_TOKEN_RE = re.compile(r"%date:([^%]+)%")
_SERVE_TOKENS = OrderedDict()
_SERVE_CAP = 256
_H3_PLAN_TYPE = "H3_CHAIN_PLAN"
_REFERENCE_TYPE = "REFERENCE"
_H3_PLAN_IMAGE_BINDINGS_KEY = "_dumas_scene_image_bindings"
_H3_PLAN_IMAGE_BINDINGS = OrderedDict()
_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(
[
(
"cinematic action movie",
"Big-screen action cinema with assertive visual storytelling: dynamic camera placement, strong "
"forward momentum, crisp geography, muscular lighting contrast, practical atmosphere, and a sense "
"of physical consequence. Favor heroic framing, controlled handheld energy or motivated tracking "
"moves, dramatic silhouettes, tasteful lens flares, impact-driven pacing, and polished studio "
"spectacle without drifting into comic-book unreality unless the shot explicitly asks for it."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"comedy",
"Play the scene for comedic readability and timing: clear staging, expressive performances, slightly "
"heightened reactions, clean eyelines, and visual beats that leave room for the joke to land. Use "
"bright approachable lighting, grounded but playful production design, readable framing, and a tone "
"that feels observant, awkward, or absurd without becoming broad parody unless the action supports it."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"found footage",
"Captured as raw found-footage material by someone physically present in the scene: imperfect handheld "
"movement, reactive reframing, clipped composition, sudden zooms or focus hunts, practical exposure "
"changes, and the feeling that the camera operator is discovering events in real time. Keep the image "
"messy but legible, accidental rather than cinematic, with authentic panic, hesitation, and ambient noise."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"90s sitcom",
"Classic 1990s multi-camera sitcom language: bright even stage lighting, cozy production design, clean "
"master coverage, medium-wide proscenium framing, fast readable blocking, and performances calibrated "
"for studio audience laughs. The world should feel warm, slightly idealized, and television-friendly, "
"with practical interiors, punchy pauses, and visual simplicity that prioritizes character business."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"mobile/cell phone captured",
"Looks like it was shot casually on a phone by an ordinary person: vertical-video instincts even when "
"the frame is horizontal, imperfect stabilization, auto-exposure breathing, clipped highlights, shallow "
"device-grade dynamic range, and opportunistic framing. Keep it intimate, immediate, and plausibly "
"amateur, with sudden pans, uncertain focus, and informal proximity that feel genuinely captured."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"news broadcast",
"Television news coverage with clear institutional polish: composed framing, practical key lighting, "
"sober color balance, authoritative pacing, and a documentarian sense of informational clarity. Favor "
"reporter or live-hit visual grammar, observational cutaway logic, steady camera operation, concise "
"emphasis, and a tone that feels current, factual, and broadcast-safe rather than entertainment-first."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"prestige tv drama",
"Prestige television drama with premium streaming polish: restrained confidence, textured naturalism, "
"nuanced performances, motivated camera movement, and carefully shaped practical lighting. The mood "
"should feel mature and expensive, with layered production design, cinematic color separation, and "
"emotionally observant framing that trusts silence and subtext." + _ANCHOR_STYLE_H3_NOTE,
),
(
"psychological thriller",
"Unsettling psychological-thriller grammar: controlled tension, negative space, ambiguous visual "
"information, subtle distortion of normality, and lighting that suggests emotional danger more than "
"physical spectacle. Favor lingering compositions, invasive close-ups, reflective surfaces, uneasy "
"stillness, and a slow escalation of dread." + _ANCHOR_STYLE_H3_NOTE,
),
(
"horror",
"Lean into horror atmosphere and threat: moody low-key lighting, oppressive space, directional contrast, "
"tactile production texture, and suspenseful framing that weaponizes what is barely seen. The scene "
"should feel unsafe and anticipatory, with sound-implied menace, uneasy empty areas in frame, and a "
"commitment to dread over cheap chaos." + _ANCHOR_STYLE_H3_NOTE,
),
(
"science fiction epic",
"Expansive science-fiction worldbuilding with polished futuristic scale: elegant production design, "
"disciplined visual effects logic, atmospheric depth, and camera language that makes technology feel "
"integrated and believable. Balance awe with clarity, using sleek surfaces, volumetric light, controlled "
"color motifs, and a sense of society beyond the immediate shot." + _ANCHOR_STYLE_H3_NOTE,
),
(
"space opera",
"Large-scale space opera with mythic emotion and visual grandeur: bold silhouettes, operatic lighting, "
"sweeping camera movement, heroic compositions, and a heightened sense of destiny. Keep the worlds rich "
"and theatrical, with dramatic color, iconic staging, and spectacle that feels earnest and adventurous."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"western",
"Cinematic western visual language: expansive landscapes, strong horizon lines, weathered textures, harsh "
"natural light, deliberate blocking, and a sense of moral tension embedded in open space. Favor patient "
"compositions, boots-on-dust realism, iconic stand-off geometry, and tactile period detail without "
"romanticizing away the grit." + _ANCHOR_STYLE_H3_NOTE,
),
(
"neo-noir",
"Neo-noir mood with urban tension and seductive darkness: hard contrast, reflective surfaces, sodium "
"vapor or neon accents, morally complicated framing, and a sense that every location hides compromise. "
"Use rain, smoke, glass, and deep shadow strategically, with stylish but controlled camera work."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"period drama",
"Refined period-drama treatment with historical texture, tailored production design, graceful camera "
"movement, and careful social observation. Light the world with soft elegance and believable practical "
"motivation, prioritizing fabric, architecture, etiquette, and emotional restraint over flashy stylization."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"documentary vérité",
"Observational documentary vérité: minimally intrusive camera behavior, natural available light, patient "
"real-time watchfulness, and an emphasis on lived-in truth over visual perfection. The frame can breathe, "
"drift, and adjust as events unfold, but it should always feel honestly present rather than staged."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"music video",
"Stylized music-video direction with strong visual authorship: rhythmic movement, bold color design, "
"heightened pose, graphic composition, and a willingness to prioritize vibe and image over literal realism. "
"Keep it polished and intent-driven, with kinetic transitions, fashion-forward staging, and synchronized intensity."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"luxury commercial",
"Premium commercial polish aimed at aspiration and desirability: immaculate composition, glossy surfaces, "
"refined lighting control, slow confident camera movement, and tactile focus on premium materials. Everything "
"should feel precise, expensive, and seductively curated, with no accidental mess unless it serves the concept."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"true crime reenactment",
"True-crime reenactment style with documentary dramatization: moody but legible lighting, restrained suspense, "
"partial visual withholding, and a tone that suggests reconstruction rather than action filmmaking. Use practical "
"locations, careful anonymity or selective detail, and sober tension that feels broadcast-doc adjacent."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"surveillance / cctv",
"Fixed surveillance-camera capture with institutional detachment: elevated or corner-mounted viewpoint, wide "
"coverage, unflattering perspective, limited dynamic range, compressed detail, and an impersonal observational "
"tone. Avoid cinematic composition; the power comes from banality, distance, and timestamp-era bluntness."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"bodycam",
"Police or security bodycam perspective: chest-mounted, wide-angle, unstable, close to breath and motion, "
"with abrupt tilts, partial occlusion, clipped framing, and urgent first-person proximity. The footage should "
"feel evidentiary and high-stress, more reactive than composed, while remaining coherent enough to follow action."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"vlog / creator video",
"Online creator or vlog visual style: personable direct address, flattering but casual framing, expressive "
"presentation, and a self-aware sense of on-camera performance. Lighting should feel creator-friendly and "
"approachable, with clean image quality, lifestyle polish, and a balance between authenticity and charisma."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"anime cinematic",
"High-end anime feature or prestige series look translated into shot design: expressive composition, dynamic "
"perspective, graphic silhouette readability, emotionally heightened staging, and clean intentional shape "
"language. Push atmosphere, color motifs, and dramatic key moments while keeping spatial storytelling clear."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"indie mumblecore",
"Low-budget indie mumblecore naturalism: intimate handheld or locked-off simplicity, imperfect but observant "
"framing, natural light or modest practicals, and performances that feel conversational rather than plot-driven. "
"Let awkward pauses, environmental plainness, and human messiness carry the tone instead of visual over-design."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"elevated fantasy",
"Sophisticated fantasy realism with mythic texture and grounded physical detail: rich environments, atmospheric "
"scale, carefully motivated magical elements, and a serious emotional register. The world should feel old, "
"inhabited, and culturally specific, with visual wonder rooted in tactile materials rather than generic glow."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"procedural cop show",
"Mainstream procedural television style: efficient visual clarity, stable coverage, lightly stylized realism, "
"and quick storytelling that privileges evidence, reactions, and spatial logic. Use functional but polished "
"lighting, confident blocking, and a tone that feels network-accessible, competent, and case-driven."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"romantic drama",
"Emotion-first romantic drama treatment: tender close observation, flattering naturalistic light, soft but not "
"mushy contrast, and framing that prioritizes vulnerability, chemistry, and meaningful silence. The scene should "
"feel intimate, earnest, and visually attentive to touch, glance, and emotional hesitation."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"grindhouse exploitation",
"Aggressive grindhouse/exploitation texture: rough-edged energy, dirty contrast, oversaturated or faded color "
"bias, abrasive zooms, tactile film wear vibes, and sensational staging that feels dangerous, pulpy, and "
"disreputable. Keep the image gleefully imperfect and confrontational rather than polished."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"video essay b-roll",
"Thoughtful video-essay b-roll style: visually articulate, idea-supporting imagery with tasteful motion, strong "
"graphic clarity, and composition that feels editorially useful. The tone should be polished but not flashy, "
"descriptive rather than dramatic, with images that communicate theme, context, and texture efficiently."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"reality tv",
"Unscripted reality-TV coverage: reactive zooms, multiple-camera energy implied in the framing, practical "
"interiors, performance-aware spontaneity, and a tone that amplifies interpersonal drama. Keep it bright, "
"legible, and emotionally available, with visual beats that feel captured in the moment rather than authored."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"mockumentary",
"Dry mockumentary television language: documentary-adjacent framing, subtle handheld correction, practical "
"lighting, awkwardly observational composition, and a restrained deadpan tone. Favor unshowy zooms, banal "
"office or domestic realism, and the sense that the camera crew is tolerated but not invisible."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"arthouse european drama",
"Arthouse European drama with patient formal control: composed frames, long takes, naturalistic light, "
"architectural blocking, and emotional understatement. Favor stillness, social texture, understated color, "
"and a feeling of psychological interiority carried by space, rhythm, and observation."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"heist thriller",
"Precision heist-thriller treatment: controlled camera motion, spatial legibility, cool confidence, metallic "
"surfaces, disciplined blocking, and tension built through logistics and timing. Keep the atmosphere sleek, "
"high-competence, and pressure-driven, with crisp visual geography and tactile procedural detail."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"war film",
"Grounded war-film intensity: unstable but purposeful camera behavior, particulate atmosphere, desaturated or "
"earth-heavy color, physical debris, and a sense of fatigue under pressure. Favor battlefield confusion held "
"inside readable geography, practical smoke, dirty textures, and severe consequential realism."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"cyberpunk neon",
"Dense cyberpunk-neon treatment: saturated artificial color, wet reflective surfaces, layered signage glow, "
"polluted atmosphere, and a future built from crowding, commerce, and exhaustion. Favor deep perspective, "
"volumetric haze, mixed light temperatures, and a sleek but grimy high-tech urban mood."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"teen drama",
"Contemporary teen-drama polish: emotionally legible framing, attractive soft-contrast lighting, heightened "
"color styling, and a tone tuned to vulnerability, longing, status, and social friction. Keep the world "
"glossy but recognizably lived-in, with image-making that feels youthful, immediate, and emotionally open."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"soap opera",
"Daytime soap-opera visual grammar: bright even lighting, clean coverage, dramatic reaction framing, polished "
"interiors, and emotional emphasis that reads quickly and clearly. Favor smooth camera operation, attractive "
"presentation, and heightened sincerity over realism or subtle restraint."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"sports broadcast",
"Live sports-broadcast treatment: long-lens observation, decisive coverage, graphic clarity, crowd-scale "
"energy, and camera choices that prioritize play readability and event momentum. Keep the look live, crisp, "
"and production-truck efficient rather than cinematic, with fast reframing and institutional polish."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"nature documentary",
"Premium nature-documentary look: patient long-lens observation, environmental atmosphere, tactile weather "
"detail, and a sense of scale that respects habitat and natural behavior. Favor pristine image clarity, "
"measured camera movement, dawn or dusk richness, and reverent attention to terrain, foliage, and light."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"travel vlogger",
"Travel-vlogger presentation with upbeat personal immediacy: lightweight camera movement, lifestyle polish, "
"sunlit color, enthusiastic visual curiosity, and framing that alternates between direct address and scenic "
"show-and-tell. Keep the image accessible, glossy, and experience-forward without becoming luxury-commercial stiff."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"cooking show",
"Food-television treatment with appetizing clarity: warm flattering light, clean overheads or medium coverage, "
"tactile ingredient detail, and a polished domestic-professional tone. Favor inviting color, crisp texture, "
"steamy atmosphere, and visual readability that makes surfaces, tools, and food prep feel satisfying."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"public access tv",
"Low-budget public-access television look: flat lighting, basic cameras, earnest staging, local-studio color, "
"and awkwardly sincere presentation. Keep the image slightly dated, mildly cheap, and charmingly unvarnished, "
"with simple framing and a community-TV sense of limited means but real enthusiasm."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"infomercial",
"Direct-response infomercial style: bright high-key lighting, clear product-first framing, emphatic readability, "
"friendly presentation, and a tone of practical persuasion. Favor obvious utility, clean set styling, smooth "
"camera movement, and visually demonstrative simplicity over mood or subtle atmosphere."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"courtroom drama",
"Courtroom-drama treatment with institutional gravity: balanced coverage, wood-and-fabric texture, controlled "
"camera moves, measured authority, and tension carried through testimony, reactions, and procedure. Keep the "
"look sober, formal, and performance-attentive, with clean eyelines and serious architectural presence."
+ _ANCHOR_STYLE_H3_NOTE,
),
(
"fantasy adventure",
"Rousing fantasy-adventure language: scenic scale, adventurous clarity, tactile costume-and-prop detail, and "
"camera movement that feels exploratory rather than oppressive. Favor storybook geography, weathered materials, "
"golden or stormy atmosphere, and a tone of peril, wonder, and forward motion."
+ _ANCHOR_STYLE_H3_NOTE,
),
]
)
_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", ""),
]
)
_BGM_PRESETS = OrderedDict(
[
(
"none",
"",
),
(
"subtle tension",
"low, restrained tension bed with sparse pulses and no vocals",
),
(
"cinematic suspense",
"cinematic suspense score with muted strings, low drones, and controlled rising pressure",
),
(
"emotional piano",
"soft emotional piano underscoring with gentle space and no vocals",
),
(
"dark ambient",
"dark ambient music bed with deep drones, distant texture, and slow unease",
),
(
"hopeful orchestral",
"hopeful orchestral underscore with warm strings, gentle brass, and restrained lift",
),
(
"retro synth",
"retro synth score with analog pulses, warm pads, and steady momentum",
),
(
"action pulse",
"driving action pulse with percussion, rhythmic bass, and urgent forward motion",
),
(
"lo-fi",
"soft lo-fi instrumental bed with mellow rhythm and warm tape texture",
),
(
"no vocals",
"instrumental background music only, no singing, no lyrics, no vocal hooks",
),
("custom", ""),
]
)
def _soundscape_options():
return list(_SOUNDSCAPE_PRESETS.keys())
def _soundscape_description(soundscape_name):
return _SOUNDSCAPE_PRESETS.get(soundscape_name, "")
def _bgm_options():
return list(_BGM_PRESETS.keys())
def _bgm_description(bgm_name):
return _BGM_PRESETS.get(bgm_name, "")
_LOAD_IMAGES_FOLDER_DEFAULT_STATE = {
"version": 1,
"folder": "",
"recursive": False,
"sort": "name",
"sort_dir": "asc",
"selected": [],
"selection_mode": "selected",
"first_n": 5,
}
def _anchor_style_options():
return list(_ANCHOR_STYLE_PRESETS.keys())
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:
cleaned = value if isinstance(value, str) else str(value)
cleaned = _MEDIA_EXT_RE.sub("", cleaned.strip())
cleaned = cleaned.replace("\\", "_").replace("/", "_")
return cleaned
def _expand_date_tokens(value):
if not isinstance(value, str) or "%date:" not in value:
return value
now = time.localtime()
def pad(number, width):
return str(number).zfill(width)
def repl(match):
fmt = match.group(1)
def swap(token_match):
token = token_match.group(0)
if token == "yyyy":
return pad(now.tm_year, 4)
if token == "yy":
return str(now.tm_year)[-2:]
if token == "MM":
return pad(now.tm_mon, 2)
if token == "M":
return str(now.tm_mon)
if token == "dd":
return pad(now.tm_mday, 2)
if token == "d":
return str(now.tm_mday)
if token in ("hh", "HH"):
return pad(now.tm_hour, 2)
if token in ("h", "H"):
return str(now.tm_hour)
if token == "mm":
return pad(now.tm_min, 2)
if token == "m":
return str(now.tm_min)
if token == "ss":
return pad(now.tm_sec, 2)
if token == "s":
return str(now.tm_sec)
return token
return re.sub(r"yyyy|yy|MM|M|dd|d|hh|h|HH|H|mm|m|ss|s", swap, fmt)
return _DATE_TOKEN_RE.sub(repl, value)
def _expand_native_tokens(value):
if not isinstance(value, str) or "%" not in value:
return value
now = time.localtime()
replacements = (
("%year%", f"{now.tm_year:04}"),
("%month%", f"{now.tm_mon:02}"),
("%day%", f"{now.tm_mday:02}"),
("%hour%", f"{now.tm_hour:02}"),
("%minute%", f"{now.tm_min:02}"),
("%second%", f"{now.tm_sec:02}"),
)
for token, replacement in replacements:
value = value.replace(token, replacement)
return value
def _safe_pattern(value):
value = str(value or "").replace("\\", "/")
value = re.sub(r'[<>:"|?*]', "_", value)
value = re.sub(r"/{2,}", "/", value).strip(" /.") or "image_%counter%"
return value
def _register_serve_token(path):
token = uuid.uuid4().hex
_SERVE_TOKENS[token] = path
while len(_SERVE_TOKENS) > _SERVE_CAP:
_SERVE_TOKENS.popitem(last=False)
return token
def resolve_serve_token(token):
return _SERVE_TOKENS.get(str(token or ""))
def _touch_plan_image_binding(token):
token = str(token or "")
if not token or token not in _H3_PLAN_IMAGE_BINDINGS:
return
binding = _H3_PLAN_IMAGE_BINDINGS.pop(token)
_H3_PLAN_IMAGE_BINDINGS[token] = binding
def _prune_plan_image_bindings():
while len(_H3_PLAN_IMAGE_BINDINGS) > _H3_PLAN_IMAGE_BINDINGS_CAP:
_H3_PLAN_IMAGE_BINDINGS.popitem(last=False)
def _clone_h3_plan(plan):
if not isinstance(plan, dict):
raise ValueError("Dumas H3 plan helpers require a plan dictionary.")
shots = plan.get("shots")
if not isinstance(shots, list):
raise ValueError("Dumas H3 plan helpers require a plan with a shots list.")
cloned = dict(plan)
cloned["shots"] = [dict(shot) if isinstance(shot, dict) else shot for shot in shots]
bindings = plan.get(_H3_PLAN_IMAGE_BINDINGS_KEY)
if isinstance(bindings, dict):
cloned[_H3_PLAN_IMAGE_BINDINGS_KEY] = {
"token": str(bindings.get("token") or ""),
"scene_counts": {
str(key): int(value)
for key, value in dict(bindings.get("scene_counts") or {}).items()
},
}
return cloned
def _normalize_h3_scene_index(plan, scene_index):
shots = plan.get("shots")
total = len(shots) if isinstance(shots, list) else 0
index = int(scene_index)
if index < 1 or index > total:
raise ValueError(
f"Dumas H3 scene index {index} is outside the plan's {total} scenes."
)
return index
def _h3_plan_binding_entry(plan):
bindings = plan.get(_H3_PLAN_IMAGE_BINDINGS_KEY)
if not isinstance(bindings, dict):
return "", {}
token = str(bindings.get("token") or "")
counts = {
str(key): int(value)
for key, value in dict(bindings.get("scene_counts") or {}).items()
}
return token, counts
def _scene_images_tuple(
image1=None,
image2=None,
image3=None,
image4=None,
image5=None,
image6=None,
image7=None,
image8=None,
image9=None,
):
return (image1, image2, image3, image4, image5, image6, image7, image8, image9)
def _connected_image_count(images):
return sum(1 for image in images if image is not None)
def _is_within_directory(parent_path, child_path):
try:
return os.path.commonpath([parent_path, child_path]) == parent_path
except ValueError:
return False
def _next_counter(directory, filename_template):
os.makedirs(directory, exist_ok=True)
if "%counter%" not in filename_template:
return 1
parts = filename_template.split("%counter%")
highest = 0
for entry in os.listdir(directory):
if not entry.startswith(parts[0]) or not entry.endswith(parts[-1]):
continue
middle = entry[len(parts[0]):]
if parts[-1]:
middle = middle[: -len(parts[-1])]
if middle.isdigit():
highest = max(highest, int(middle))
return highest + 1
def _next_counter_for_relative_path(base_directory, relative_template):
os.makedirs(base_directory, exist_ok=True)
if "%counter%" not in relative_template:
return 1
counter = 1
while True:
candidate = relative_template.replace("%counter%", str(counter).zfill(3))
full_path = os.path.join(base_directory, *[part for part in candidate.split("/") if part])
if not os.path.exists(full_path):
return counter
counter += 1
def _build_pnginfo(prompt=None, extra_pnginfo=None):
try:
pnginfo = Image.PngImagePlugin.PngInfo()
except AttributeError:
from PIL.PngImagePlugin import PngInfo
pnginfo = PngInfo()
if prompt is not None:
pnginfo.add_text("prompt", json.dumps(prompt))
if isinstance(extra_pnginfo, dict):
for key, value in extra_pnginfo.items():
pnginfo.add_text(str(key), json.dumps(value))
return pnginfo
def _tensor_image_to_pil_image(tensor):
image_tensor = tensor[0]
if hasattr(image_tensor, "mul") and hasattr(image_tensor, "clamp"):
image_array = image_tensor.mul(255).clamp(0, 255)
if hasattr(image_array, "byte"):
image_array = image_array.byte()
image_array = image_array.cpu().numpy()
return Image.fromarray(image_array)
image_array = 255.0 * image_tensor.cpu().numpy()
return Image.fromarray(np.clip(image_array, 0, 255).astype(np.uint8))
def _folder_loader_default_state():
return dict(_LOAD_IMAGES_FOLDER_DEFAULT_STATE)
def _parse_load_images_folder_state(state_json):
if not state_json:
return _folder_loader_default_state()
try:
parsed = json.loads(state_json)
except Exception:
return _folder_loader_default_state()
state = _folder_loader_default_state()
if isinstance(parsed, dict):
state.update({key: value for key, value in parsed.items() if key in state})
return state
def _folder_is_image(name):
return str(name or "").lower().endswith(_FOLDER_IMAGE_EXTS)
def _list_folder_image_files(real_folder, recursive):
files = []
if recursive:
for root, _dirs, names in os.walk(real_folder):
for name in names:
if not _folder_is_image(name):
continue
full_path = os.path.join(root, name)
try:
stat_result = os.stat(full_path)
except OSError:
continue
rel_path = os.path.relpath(full_path, real_folder).replace("\\", "/")
files.append(
{
"file": rel_path,
"name": name,
"size": stat_result.st_size,
"mtime": stat_result.st_mtime,
}
)
else:
for name in os.listdir(real_folder):
full_path = os.path.join(real_folder, name)
if not os.path.isfile(full_path) or not _folder_is_image(name):
continue
try:
stat_result = os.stat(full_path)
except OSError:
continue
files.append(
{
"file": name,
"name": name,
"size": stat_result.st_size,
"mtime": stat_result.st_mtime,
}
)
return files
def _sort_folder_image_files(files, sort_key, sort_dir):
ordered = list(files or [])
def sort_value(entry):
if sort_key == "date":
return (float(entry.get("mtime") or 0), str(entry.get("file") or "").lower())
return str(entry.get("file") or "").lower()
ordered.sort(key=sort_value, reverse=str(sort_dir or "").lower() == "desc")
return ordered
def _resolve_folder_selection(state, files):
ordered = _sort_folder_image_files(
files,
state.get("sort", "name"),
state.get("sort_dir", "asc"),
)
mode = str(state.get("selection_mode") or "selected").lower()
if mode == "all":
return [entry["file"] for entry in ordered]
if mode == "first_n":
try:
count = max(0, int(state.get("first_n", 0) or 0))
except Exception:
count = 0
return [entry["file"] for entry in ordered[:count]]
if mode == "random":
return [random.choice(ordered)["file"]] if ordered else []
present = {entry["file"] for entry in ordered}
selected = []
for rel_path in state.get("selected", []) or []:
if isinstance(rel_path, str) and rel_path in present:
selected.append(rel_path)
return selected
def _load_folder_image(path):
import numpy as np
try:
import torch
except Exception as exc:
raise RuntimeError("torch is required to load folder images") from exc
from PIL import Image, ImageOps, ImageSequence
try:
import comfy.model_management as comfy_model_management
tensor_dtype = comfy_model_management.intermediate_dtype()
except Exception:
tensor_dtype = torch.float32
try:
import node_helpers
image = node_helpers.pillow(Image.open, path)
except Exception:
image = Image.open(path)
frame = ImageOps.exif_transpose(next(ImageSequence.Iterator(image)))
if frame.mode == "I":
frame = frame.point(lambda px: px * (1 / 255))
rgb_image = frame.convert("RGB")
width, height = rgb_image.size
if "A" in frame.getbands():
alpha = np.array(frame.getchannel("A")).astype(np.float32) / 255.0
mask_image = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
elif frame.mode == "P" and "transparency" in frame.info:
alpha = np.array(frame.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0
mask_image = Image.fromarray(((1.0 - alpha) * 255).astype(np.uint8), mode="L")
else:
mask_image = Image.new("L", rgb_image.size, 0)
image_tensor = torch.from_numpy(np.array(rgb_image).astype(np.float32) / 255.0)[None,].to(
dtype=tensor_dtype
)
mask_tensor = torch.from_numpy(np.array(mask_image).astype(np.float32) / 255.0).unsqueeze(0).to(
dtype=tensor_dtype
)
return image_tensor, mask_tensor, int(width), int(height)
def _normalize_free_text(value):
return " ".join(str(value or "").split()).strip()
def _label_for_character(name, character_id):
return _normalize_free_text(name) or _normalize_free_text(character_id) or "the character"
def _format_height_text(feet, inches):
feet_value = str(feet or "").strip()
inches_value = str(inches or "").strip()
if not feet_value and not inches_value:
return ""
parts = []
if feet_value:
feet_number = int(feet_value)
parts.append(f"{feet_number} foot" if feet_number == 1 else f"{feet_number} feet")
if inches_value:
inches_number = int(inches_value)
parts.append(
f"{inches_number} inch" if inches_number == 1 else f"{inches_number} inches"
)
return " ".join(parts)
def _ensure_sentence(value):
text = _normalize_free_text(value)
if not text:
return ""
if text[-1] not in ".!?":
text += "."
return text
def _slug_like(value):
text = _normalize_free_text(value).lower()
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
return text
def _parse_aliases(value):
if isinstance(value, (list, tuple)):
raw_items = value
else:
raw_items = re.split(r"[,;\n\r]+", str(value or ""))
aliases = []
seen = set()
for item in raw_items:
alias = _normalize_free_text(item)
if not alias:
continue
key = alias.lower()
if key in seen:
continue
seen.add(key)
aliases.append(alias)
return aliases
def _coerce_picture_id(value):
try:
number = int(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _reference_id(explicit_id, name, fallback_prefix):
explicit = _slug_like(explicit_id)
if explicit:
return explicit
derived = _slug_like(name)
if derived:
return derived
return f"{fallback_prefix}-{uuid.uuid4().hex[:8]}"
def _reference_label(picture_id):
return f"<Picture {picture_id}>" if picture_id else ""
def _reference_summary(kind, name, picture_id):
label = _reference_label(picture_id)
subject = _normalize_free_text(name) or ("character" if kind == "character" else "location")
if label:
return f"{subject} shown in {label}."
return f"{subject} reference."
def make_reference(
*,
kind,
image,
explicit_id="",
name="",
aliases="",
picture_id=None,
description="",
wardrobe="",
general="",
facts=None,
summary="",
):
normalized_name = _normalize_free_text(name)
normalized_aliases = _parse_aliases(aliases)
normalized_picture_id = _coerce_picture_id(picture_id)
normalized_kind = "location" if str(kind or "").strip().lower() == "location" else "character"
normalized_description = _normalize_free_text(description)
normalized_wardrobe = _normalize_free_text(wardrobe)
normalized_general = _normalize_free_text(general)
normalized_facts = dict(facts or {})
normalized_summary = _ensure_sentence(
summary or _reference_summary(normalized_kind, normalized_name, normalized_picture_id)
)
return {
"kind": normalized_kind,
"id": _reference_id(explicit_id, normalized_name, normalized_kind),
"name": normalized_name,
"aliases": normalized_aliases,
"picture_id": normalized_picture_id,
"picture_label": _reference_label(normalized_picture_id),
"image": image,
"summary": normalized_summary,
"description": normalized_description,
"wardrobe": normalized_wardrobe if normalized_kind == "character" else "",
"general": normalized_general,
"facts": normalized_facts,
}
def normalize_reference(value, picture_id=None, allow_image_fallback=True):
if isinstance(value, dict):
reference = dict(value)
image = reference.get("image")
if image is None and allow_image_fallback:
image = value
reference["image"] = image
existing_picture_id = _coerce_picture_id(reference.get("picture_id"))
previous_summary = _ensure_sentence(reference.get("summary") or "")
if picture_id is not None and not reference.get("picture_id"):
reference["picture_id"] = _coerce_picture_id(picture_id)
reference["picture_label"] = _reference_label(reference.get("picture_id"))
reference.setdefault("kind", "character")
reference.setdefault("id", _reference_id("", reference.get("name"), reference["kind"]))
reference.setdefault("name", "")
reference["aliases"] = _parse_aliases(reference.get("aliases"))
auto_summary_before = _ensure_sentence(
_reference_summary(reference["kind"], reference.get("name"), existing_picture_id)
)
auto_summary_after = _ensure_sentence(
_reference_summary(reference["kind"], reference.get("name"), reference.get("picture_id"))
)
if not previous_summary or previous_summary == auto_summary_before:
reference["summary"] = auto_summary_after
else:
reference["summary"] = previous_summary
reference["description"] = _normalize_free_text(reference.get("description"))
reference["wardrobe"] = _normalize_free_text(reference.get("wardrobe"))
reference["general"] = _normalize_free_text(reference.get("general"))
reference["facts"] = dict(reference.get("facts") or {})
return reference
if not allow_image_fallback:
raise TypeError("Expected a REFERENCE object.")
return make_reference(
kind="character",
image=value,
picture_id=picture_id,
summary="Plan-bound fallback reference.",
)
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="",
bgm="",
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)
_append_prompt_section(prompt_parts, "background_music", bgm)
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:
return None
try:
parsed = int(text)
except (TypeError, ValueError):
return None
if parsed <= 0:
return None
return parsed
def _indefinite_article(value):
text = _normalize_free_text(value).lower()
if not text:
return "a"
return "an" if text[0] in "aeiou" else "a"
def _build_character_helper_text(
primary_picture_id,
secondary_picture_id,
character_id,
name,
alias,
gender,
age,
nationality,
occupation,
height_feet,
height_inches,
accent,
general,
):
primary_picture = int(primary_picture_id)
secondary_picture = int(secondary_picture_id)
character_name = _normalize_free_text(name)
character_id = _normalize_free_text(character_id)
alias = _normalize_free_text(alias)
gender = _normalize_free_text(gender)
nationality = _normalize_free_text(nationality)
occupation = _normalize_free_text(occupation)
accent = _normalize_free_text(accent)
general = _ensure_sentence(general)
age_value = _parse_positive_int(age)
character_label = _label_for_character(character_name, character_id)
if character_name:
first_line = (
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
f"the same character who is called {character_name}."
)
elif character_id:
first_line = (
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
f'the same character with ID "{character_id}".'
)
else:
first_line = (
f"<Picture {primary_picture}> and <Picture {secondary_picture}> reference "
"the same character."
)
lines = [
first_line,
f"<Picture {primary_picture}> is the primary full-body reference for {character_label}.",
f"<Picture {secondary_picture}> is a frontal facial reference for {character_label}.",
]
fact_fragments = []
if alias:
fact_fragments.append(f"is also known as {alias}")
if gender:
fact_fragments.append(f"is {gender}")
if age_value is not None:
fact_fragments.append(f"is {age_value} years old")
if nationality:
fact_fragments.append(f"is {nationality}")
if occupation:
fact_fragments.append(f"works as {occupation}")
height_text = _format_height_text(height_feet, height_inches)
if height_text:
fact_fragments.append(f"is {height_text} tall")
if accent:
fact_fragments.append(
f"speaks in {_indefinite_article(accent)} {accent} accent"
)
if fact_fragments:
lines.append(f"{character_label} {', '.join(fact_fragments)}.")
if general:
lines.append(general)
return "\n".join(lines)
def _build_character_wardrobe_text(wardrobe, character_id, name, alias):
text = _normalize_free_text(wardrobe)
if not text:
return ""
if text.lower().startswith("wardrobe:"):
text = text.split(":", 1)[1].strip()
if not text:
return ""
# If the user already authored a full H3-style sheet entry, leave it alone.
if "=" in text or ":" in text:
return text
character_label = (
_normalize_free_text(name)
or _normalize_free_text(alias)
or _normalize_free_text(character_id)
)
if character_label:
return f"{character_label} = {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:
DESCRIPTION = (
"Dumas Image Compare shows the difference between two images directly on "
"the node. Connect one or two IMAGE inputs to compare before/after "
"results, model variants, or processing stages without breaking a "
"workflow when one branch is bypassed."
)
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("new image",)
FUNCTION = "compare_images"
OUTPUT_NODE = True
CATEGORY = "Dumas/Image"
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
self.prefix_append = "_dumascmp_" + "".join(
random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5)
)
self.compress_level = 4
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"image1": (
"IMAGE",
{
"tooltip": (
"First image to compare. Optional so muted or bypassed "
"branches do not trigger a missing-input error."
)
},
),
"image2": (
"IMAGE",
{
"tooltip": (
"Second image to compare. Optional so the node can still "
"display a single available image."
)
},
),
}
}
def compare_images(self, image1=None, image2=None):
present = []
if image1 is not None:
present.append((1, image1))
if image2 is not None:
present.append((2, image2))
results = []
if present:
first_tensor = present[0][1]
prefix = "dumas_compare" + self.prefix_append
first_image = first_tensor[0]
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
prefix,
self.output_dir,
first_image.shape[1],
first_image.shape[0],
)
join_path = os.path.join
for slot, tensor in present:
image = _tensor_image_to_pil_image(tensor)
file_name = f"{filename}_{counter:05}_.png"
image.save(
join_path(full_output_folder, file_name),
compress_level=self.compress_level,
)
results.append(
{
"filename": file_name,
"subfolder": subfolder,
"type": self.type,
"slot": slot,
}
)
counter += 1
new_image = image2 if image2 is not None else image1
return {"ui": {"images": results}, "result": (new_image,)}
class DumasSaveImageNode:
DESCRIPTION = (
"Dumas Save Image writes images to any folder, with filename tokens "
"such as %input%, %input2%, %date:yyyy-MM-dd%, %counter%, %width%, "
"%height%, and %batch_num%."
)
RETURN_TYPES = ()
FUNCTION = "save_images"
OUTPUT_NODE = True
CATEGORY = "Dumas/Image"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE", {"tooltip": "Image batch to save."}),
"folder": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": (
"Destination folder. Leave empty to save in ComfyUI's "
"output directory."
),
},
),
"pattern": (
"STRING",
{
"default": "renders/%date:yyyy-MM-dd%/image_%counter%",
"multiline": False,
"tooltip": (
"Filename pattern with optional subfolders. Tokens: "
"%input%, %input2%, %date:yyyy-MM-dd%, %counter%, "
"%width%, %height%, %batch_num%."
),
},
),
"format": (
["png", "jpg"],
{
"default": "png",
"tooltip": "Output image format. PNG is the safe default for lossless saves and workflow metadata."
},
),
"quality": (
"INT",
{
"default": 95,
"min": 1,
"max": 100,
"step": 1,
"tooltip": "JPEG quality when format=jpg. Ignored for PNG. 95 is a realistic high-quality default."
},
),
"embed_workflow": (
"BOOLEAN",
{
"default": True,
"tooltip": "Embed prompt/workflow metadata into PNG saves when possible."
},
),
"save_on_run": (
"BOOLEAN",
{
"default": True,
"tooltip": "Save files when the node executes. Turn off to keep wiring in place without writing files."
},
),
},
"optional": {
"name": (
"STRING",
{
"forceInput": True,
"tooltip": "Optional text inserted by the %input% token.",
},
),
"name_2": (
"STRING",
{
"forceInput": True,
"tooltip": "Optional text inserted by the %input2% token.",
},
),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
@classmethod
def IS_CHANGED(cls, **_kwargs):
return float("nan")
def save_images(
self,
images,
folder,
pattern,
format,
quality,
embed_workflow,
save_on_run,
name=None,
name_2=None,
prompt=None,
extra_pnginfo=None,
):
if not save_on_run:
return {"ui": {"images": []}}
width = int(images.shape[2])
height = int(images.shape[1])
output_dir = folder_paths.get_output_directory()
target_dir = os.path.abspath(folder.strip()) if str(folder or "").strip() else output_dir
os.makedirs(target_dir, exist_ok=True)
resolved_pattern = str(pattern or "image_%date:yyyy-MM-dd%_%counter%")
resolved_pattern = resolved_pattern.replace("%input%", _clean_input_token_value(name))
resolved_pattern = resolved_pattern.replace("%input2%", _clean_input_token_value(name_2))
resolved_pattern = _expand_date_tokens(resolved_pattern)
resolved_pattern = _expand_native_tokens(resolved_pattern)
resolved_pattern = resolved_pattern.replace("%width%", str(width))
resolved_pattern = resolved_pattern.replace("%height%", str(height))
resolved_pattern = _safe_pattern(resolved_pattern)
extension = ".jpg" if format == "jpg" else ".png"
quality = max(1, min(100, int(quality)))
ui_images = []
for batch_index in range(images.shape[0]):
frame_pattern = resolved_pattern.replace("%batch_num%", str(batch_index))
frame_parts = [part for part in frame_pattern.split("/") if part]
relative_template = "/".join(frame_parts[:-1] + [((frame_parts[-1] if frame_parts else "image_%counter%") + extension)])
counter = _next_counter_for_relative_path(target_dir, relative_template)
resolved_relative = relative_template.replace("%counter%", str(counter).zfill(3))
resolved_parts = [part for part in resolved_relative.split("/") if part]
sub_dirs = resolved_parts[:-1]
filename = resolved_parts[-1] if resolved_parts else f"image_{str(counter).zfill(3)}{extension}"
frame_dir = os.path.join(target_dir, *sub_dirs)
os.makedirs(frame_dir, exist_ok=True)
image = _tensor_image_to_pil_image(images[batch_index : batch_index + 1])
full_path = os.path.join(frame_dir, filename)
if format == "jpg":
image = image.convert("RGB")
image.save(full_path, "JPEG", quality=quality)
else:
pnginfo = None
if embed_workflow:
pnginfo = _build_pnginfo(prompt=prompt, extra_pnginfo=extra_pnginfo)
image.save(full_path, "PNG", pnginfo=pnginfo)
if _is_within_directory(output_dir, full_path):
subfolder = os.path.relpath(frame_dir, output_dir)
ui_images.append(
{
"filename": filename,
"subfolder": "" if subfolder == "." else subfolder.replace("\\", "/"),
"type": "output",
}
)
else:
ui_images.append(
{
"filename": filename,
"subfolder": frame_dir.replace("\\", "/"),
"type": "external",
"token": _register_serve_token(full_path),
}
)
return {"ui": {"images": ui_images}}
class DumasLoadImagesFolderNode:
DESCRIPTION = (
"Load many images from any folder on disk and feed them through your "
"workflow one at a time. Pick specific images, all images, the first N "
"images in sort order, or one random image per run."
)
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = ("image", "mask", "width", "height", "filename", "index", "total")
OUTPUT_IS_LIST = (True, True, True, True, True, True, True)
FUNCTION = "load"
CATEGORY = "Dumas/Image"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"hidden": {
"LoadImagesFolderState": (
"STRING",
{"default": json.dumps(_LOAD_IMAGES_FOLDER_DEFAULT_STATE)},
),
},
}
def load(self, LoadImagesFolderState=""):
state = _parse_load_images_folder_state(LoadImagesFolderState)
folder = str(state.get("folder") or "").strip()
recursive = bool(state.get("recursive", False))
if not folder or not os.path.isdir(folder):
raise ValueError(
"Load Images from Folder: folder not found. Set a folder on the node first."
)
real_folder = os.path.realpath(folder)
files = _list_folder_image_files(real_folder, recursive)
selected = _resolve_folder_selection(state, files)
mode = str(state.get("selection_mode") or "selected").lower()
if not selected:
if mode == "random":
raise ValueError(
"Load Images from Folder: no images found for random selection."
)
raise ValueError(
"Load Images from Folder: no images selected. Use Pick images on the node."
)
images = []
masks = []
widths = []
heights = []
names = []
indices = []
count = 0
for rel_path in selected:
if not isinstance(rel_path, str) or not rel_path:
continue
full_path = os.path.realpath(os.path.join(real_folder, rel_path))
if not _is_within_directory(real_folder, full_path) or not os.path.isfile(full_path):
continue
try:
image_tensor, mask_tensor, width, height = _load_folder_image(full_path)
except Exception as exc:
print(f"[DumasLoadImagesFolder] failed to load {rel_path}: {exc}")
continue
images.append(image_tensor)
masks.append(mask_tensor)
widths.append(width)
heights.append(height)
if recursive:
names.append(os.path.splitext(rel_path)[0].replace("/", "_").replace("\\", "_"))
else:
names.append(os.path.splitext(os.path.basename(rel_path))[0])
count += 1
indices.append(count)
if not images:
raise ValueError(
"Load Images from Folder: none of the chosen images could be loaded."
)
totals = [count] * len(images)
return (images, masks, widths, heights, names, indices, totals)
@classmethod
def IS_CHANGED(cls, LoadImagesFolderState=""):
state = _parse_load_images_folder_state(LoadImagesFolderState)
folder = str(state.get("folder") or "").strip()
if not folder or not os.path.isdir(folder):
return hashlib.sha256((LoadImagesFolderState or "").encode("utf-8")).hexdigest()
real_folder = os.path.realpath(folder)
files = _list_folder_image_files(real_folder, bool(state.get("recursive", False)))
mode = str(state.get("selection_mode") or "selected").lower()
parts = [json.dumps({k: v for k, v in state.items() if k != "selected"}, sort_keys=True)]
if mode == "random":
for entry in _sort_folder_image_files(files, state.get("sort", "name"), state.get("sort_dir", "asc")):
parts.append(f"{entry['file']}:{entry.get('mtime', 0)}")
parts.append(f"random:{time.time_ns()}")
else:
for rel_path in _resolve_folder_selection(state, files):
full_path = os.path.realpath(os.path.join(real_folder, rel_path))
if not _is_within_directory(real_folder, full_path):
parts.append(f"{rel_path}:outside")
continue
try:
parts.append(f"{rel_path}:{os.stat(full_path).st_mtime_ns}")
except OSError:
parts.append(f"{rel_path}:missing")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
class DumasH3PlanAttachSceneImagesNode:
DESCRIPTION = (
"Attach up to nine optional IMAGE sockets to one H3 Chain Plan scene "
"without breaking the upstream plan archive format. Chain multiple "
"copies of this node to bind different scene indexes."
)
RETURN_TYPES = (_H3_PLAN_TYPE, "INT")
RETURN_NAMES = ("plan", "connected_images")
FUNCTION = "attach"
CATEGORY = "Dumas/MiniMax"
@classmethod
def INPUT_TYPES(cls):
optional = {}
for slot in range(1, _H3_PLAN_IMAGE_SLOTS + 1):
optional[f"image{slot}"] = (
"IMAGE",
{
"tooltip": (
f"Optional image for slot {slot} on the selected H3 plan scene."
)
},
)
return {
"required": {
"plan": (
_H3_PLAN_TYPE,
{
"tooltip": (
"Validated MiniMax H3 chain plan to enrich with scene-level "
"image bindings."
)
},
),
"scene_index": (
"INT",
{
"default": 1,
"min": 1,
"max": 9999,
"step": 1,
"tooltip": (
"1-based scene index inside the H3 plan. Use one node per "
"scene that needs up to nine image sockets."
),
},
),
},
"optional": optional,
}
def attach(
self,
plan,
scene_index,
image1=None,
image2=None,
image3=None,
image4=None,
image5=None,
image6=None,
image7=None,
image8=None,
image9=None,
):
updated_plan = _clone_h3_plan(plan)
scene_index = _normalize_h3_scene_index(updated_plan, scene_index)
images = _scene_images_tuple(
image1,
image2,
image3,
image4,
image5,
image6,
image7,
image8,
image9,
)
connected_count = _connected_image_count(images)
token, scene_counts = _h3_plan_binding_entry(updated_plan)
if not token:
token = uuid.uuid4().hex
registry = _H3_PLAN_IMAGE_BINDINGS.setdefault(token, {})
_touch_plan_image_binding(token)
if connected_count:
registry[int(scene_index)] = images
scene_counts[str(scene_index)] = connected_count
else:
registry.pop(int(scene_index), None)
scene_counts.pop(str(scene_index), None)
if registry:
updated_plan[_H3_PLAN_IMAGE_BINDINGS_KEY] = {
"token": token,
"scene_counts": scene_counts,
}
else:
_H3_PLAN_IMAGE_BINDINGS.pop(token, None)
updated_plan.pop(_H3_PLAN_IMAGE_BINDINGS_KEY, None)
_prune_plan_image_bindings()
return (updated_plan, connected_count)
class DumasH3PlanExtractSceneImagesNode:
DESCRIPTION = (
"Read back the nine optional image bindings for one H3 Chain Plan scene. "
"Connect clip_index or another scene selector to recover the matching "
"scene images downstream."
)
RETURN_TYPES = (
_H3_PLAN_TYPE,
"IMAGE",
"IMAGE",
"IMAGE",
"IMAGE",
"IMAGE",
"IMAGE",
"IMAGE",
"IMAGE",
"IMAGE",
"INT",
)
RETURN_NAMES = (
"plan",
"image1",
"image2",
"image3",
"image4",
"image5",
"image6",
"image7",
"image8",
"image9",
"connected_images",
)
FUNCTION = "extract"
CATEGORY = "Dumas/MiniMax"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"plan": (
_H3_PLAN_TYPE,
{
"tooltip": (
"H3 plan previously enriched by Dumas H3 Plan Attach Scene Images."
)
},
),
"scene_index": (
"INT",
{
"default": 1,
"min": 1,
"max": 9999,
"step": 1,
"tooltip": (
"1-based scene index to retrieve. Connect Current Shot "
"clip_index to get the active scene's images."
),
},
),
}
}
def extract(self, plan, scene_index):
passthrough_plan = _clone_h3_plan(plan)
scene_index = _normalize_h3_scene_index(passthrough_plan, scene_index)
token, _scene_counts = _h3_plan_binding_entry(passthrough_plan)
if not token:
return (passthrough_plan, *(None for _ in range(_H3_PLAN_IMAGE_SLOTS)), 0)
registry = _H3_PLAN_IMAGE_BINDINGS.get(token) or {}
_touch_plan_image_binding(token)
images = registry.get(int(scene_index)) or (None,) * _H3_PLAN_IMAGE_SLOTS
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 "
"the image, identity description, wardrobe, and facts through one socket."
)
RETURN_TYPES = (_REFERENCE_TYPE,)
RETURN_NAMES = ("reference",)
FUNCTION = "build_reference"
CATEGORY = "Dumas/MiniMax"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Character reference image."}),
"character_id": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Stable internal identifier for the character, for example 'francine-main'.",
},
),
"name": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Human-readable character name, for example 'Francine'.",
},
),
"alias": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Comma- or newline-separated aliases for name matching, for example 'Fran, Frankie'.",
},
),
"gender": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional gender field for non-visual character facts, for example 'woman'.",
},
),
"age": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional numeric age. Invalid values are omitted, for example '29'.",
},
),
"nationality": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional nationality, origin, or cultural background, for example 'French'.",
},
),
"occupation": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Optional job, role, or function that is not visually obvious, for example 'pilot'.",
},
),
"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 accent or speaking-style fact, for example 'soft Parisian accent'.",
},
),
"description": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Persistent physical identity description for the character, for example 'short silver hair, scar over the left eyebrow, slim build'.",
},
),
"general": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional freeform notes or extra context, for example 'grounded movement, confident but understated presence'.",
},
),
"wardrobe": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Persistent clothing, styling, accessories, or look notes, for example 'white blouse, black trousers, gold hoop earrings'.",
},
),
}
}
def build_reference(
self,
image,
character_id,
name,
alias,
gender,
age,
nationality,
occupation,
height_feet,
height_inches,
accent,
description,
general,
wardrobe,
):
reference = make_reference(
kind="character",
image=image,
explicit_id=character_id,
name=name,
aliases=alias,
description=description,
wardrobe=wardrobe,
general=general,
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),
},
)
return (reference,)
class DumasLocationReferenceNode:
DESCRIPTION = (
"Build one structured REFERENCE object for a location or environment so "
"H3 can carry the image and environment description through one socket."
)
RETURN_TYPES = (_REFERENCE_TYPE,)
RETURN_NAMES = ("reference",)
FUNCTION = "build_reference"
CATEGORY = "Dumas/MiniMax"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Location or environment reference image."}),
"location_id": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Stable internal identifier for the location, for example 'corner-coffee-shop'.",
},
),
"name": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Human-readable location name, for example 'Corner Coffee Shop'.",
},
),
"alias": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": "Comma- or newline-separated alternate location names, for example 'cafe, front seating area'.",
},
),
"description": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Persistent environment, layout, and atmosphere description, for example 'warm coffee shop interior with amber practical lighting, pale wood tables, and front windows'.",
},
),
"general": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional freeform location notes, for example 'keep it grounded and realistic; use the front seating area as the play space'.",
},
),
}
}
def build_reference(self, image, location_id, name, alias, description, general):
return (
make_reference(
kind="location",
image=image,
explicit_id=location_id,
name=name,
aliases=alias,
description=description,
general=general,
facts={},
),
)
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 DumasBackgroundMusicHelperNode:
DESCRIPTION = (
"Choose a background music preset, auto-fill its editable description, "
"and pass the final BGM text downstream for MiniMax H3 prompts."
)
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("bgm",)
FUNCTION = "build_bgm"
CATEGORY = "Dumas/MiniMax"
@classmethod
def INPUT_TYPES(cls):
default_bgm = "none"
return {
"required": {
"bgm": (
_bgm_options(),
{
"default": default_bgm,
"tooltip": "Preset title used to seed the editable background music description.",
},
),
"bgm_description": (
"STRING",
{
"default": _bgm_description(default_bgm),
"multiline": True,
"tooltip": (
"Editable background music description. Whatever text is here "
"is what the node outputs to the bgm socket."
),
},
),
}
}
def build_bgm(self, bgm, bgm_description):
text = str(bgm_description or "").strip()
if not text:
text = _bgm_description(bgm)
return (text,)
class DumasH3PromptCuratorNode:
DESCRIPTION = (
"Curate one MiniMax H3 prompt from an action textbox, anchor text, "
"soundscape/BGM 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.",
},
),
"bgm": (
"STRING",
{
"forceInput": True,
"tooltip": "Optional background music text, usually from Dumas Background Music 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="",
bgm="",
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,
bgm=bgm,
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 "
"the editable description downstream as one STRING value."
)
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("anchor",)
FUNCTION = "build_anchor"
CATEGORY = "Dumas/MiniMax"
@classmethod
def INPUT_TYPES(cls):
default_style = "cinematic action movie"
return {
"required": {
"anchor_style": (
_anchor_style_options(),
{
"default": default_style,
"tooltip": "Preset title used to seed the editable anchor description.",
},
),
"style_description": (
"STRING",
{
"default": _anchor_style_description(default_style),
"multiline": True,
"tooltip": (
"Editable anchor style description. The dropdown can populate this field, but "
"whatever text is here is what the node outputs to the anchor socket."
),
},
),
}
}
def build_anchor(self, anchor_style, style_description):
text = _clean_anchor_style_text(style_description)
if not text:
text = _clean_anchor_style_text(_anchor_style_description(anchor_style))
return (text,)
NODE_CLASS_MAPPINGS = {
"DumasImageCompare": DumasImageCompareNode,
"DumasSaveImage": DumasSaveImageNode,
"DumasLoadImagesFolder": DumasLoadImagesFolderNode,
"DumasH3PlanAttachSceneImages": DumasH3PlanAttachSceneImagesNode,
"DumasH3PlanExtractSceneImages": DumasH3PlanExtractSceneImagesNode,
"DumasCharacterReference": DumasCharacterReferenceNode,
"DumasLocationReference": DumasLocationReferenceNode,
"DumasSoundscapeHelper": DumasSoundscapeHelperNode,
"DumasBackgroundMusicHelper": DumasBackgroundMusicHelperNode,
"DumasH3PromptCurator": DumasH3PromptCuratorNode,
"DumasAnchorStyle": DumasAnchorStyleNode,
"DumasCharacterHelper": DumasCharacterHelperNode,
"DumasLocationHelper": DumasLocationHelperNode,
"DumasH3CharacterHelper": DumasCharacterHelperNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DumasImageCompare": "Dumas Image Compare",
"DumasSaveImage": "Save Image Dumas",
"DumasLoadImagesFolder": "Load Images from Folder Dumas",
"DumasH3PlanAttachSceneImages": "Dumas H3 Plan Attach Scene Images",
"DumasH3PlanExtractSceneImages": "Dumas H3 Plan Extract Scene Images",
"DumasCharacterReference": "Dumas Character Reference",
"DumasLocationReference": "Dumas Location Reference",
"DumasSoundscapeHelper": "Dumas Soundscape Helper",
"DumasBackgroundMusicHelper": "Dumas Background Music Helper",
"DumasH3PromptCurator": "Dumas H3 Prompt Curator",
"DumasAnchorStyle": "Dumas Anchor Style",
"DumasCharacterHelper": "Dumas Character Helper",
"DumasLocationHelper": "Dumas Location Helper",
"DumasH3CharacterHelper": "Dumas Character Helper",
}