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 = "" _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", ""), ] ) 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": "", "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_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"" 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"" rewritten = _PICTURE_TAG_RE.sub(repl, str(text or "")) rewritten = _REF_TAG_RE.sub(repl, rewritten) return re.sub(r"[ \t]{2,}", " ", rewritten).strip() def _reference_fact_sentence(ref, label): if ref.get("kind") != "character": return "" facts = dict(ref.get("facts") or {}) bits = [] aliases = [_reference_text(alias) for alias in (ref.get("aliases") or []) if _reference_text(alias)] if aliases: bits.append(f"also known as {aliases[0]}") for key in ("gender", "nationality", "occupation"): value = _reference_text(facts.get(key)) if value: bits.append(value if key != "occupation" else f"works as {value}") age = _parse_positive_int(facts.get("age")) if age is not None: bits.append(f"{age} years old") feet = _reference_text(facts.get("height_feet")) inches = _reference_text(facts.get("height_inches")) if feet and inches: bits.append(f"{feet} foot {inches} tall") elif feet: bits.append(f"{feet} foot tall") accent = _reference_text(facts.get("accent")) if accent: bits.append(f"speaks with a {accent} accent") if not bits: return "" return f"Character facts for {label}: " + ", ".join(bits) + "." def _reference_context(ref, compact_picture_number): label_name = _reference_text(ref.get("name")) or _reference_text(ref.get("id")) or "this reference" label = f" {label_name}" parts = [_reference_sentence(_reference_summary(ref.get("kind"), label_name, compact_picture_number))] description = _reference_sentence(ref.get("description")) wardrobe = _reference_sentence(ref.get("wardrobe")) general = _reference_sentence(ref.get("general")) facts = _reference_fact_sentence(ref, label) if ref.get("kind") == "location": if description: parts.append(f"Location context for {label}: {description}") if general: parts.append(f"Location notes for {label}: {general}") else: if facts: parts.append(facts) if description: parts.append(f"Persistent appearance for {label}: {description}") if wardrobe: parts.append(f"Persistent wardrobe/style for {label}: {wardrobe}") if general: parts.append(f"Character notes for {label}: {general}") return " ".join(part for part in parts if part).strip() def _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"" 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}-> {_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" and reference " f"the same character who is called {character_name}." ) elif character_id: first_line = ( f" and reference " f'the same character with ID "{character_id}".' ) else: first_line = ( f" and reference " "the same character." ) lines = [ first_line, f" is the primary full-body reference for {character_label}.", f" 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" and reference " f"the same location called {location_name}." ) elif location_id: first_line = ( f" and reference " f'the same location with ID "{location_id}".' ) else: first_line = ( f" and reference " "the same location." ) lines = [ first_line, f" is the primary wide/environment reference for {location_label}.", f" 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") RETURN_NAMES = ("image1", "image2", "reference_prompt") FUNCTION = "build_location_text" CATEGORY = "Dumas/MiniMax" @classmethod def INPUT_TYPES(cls): return { "required": { "image1": ("IMAGE", {"tooltip": "Primary location image to pass through and describe."}), "image2": ("IMAGE", {"tooltip": "Secondary location image to pass through and describe."}), "image1_picture_id": ( ["1", "2", "3", "4", "5", "6", "7", "8", "9"], { "default": "1", "tooltip": "Picture number to mention for image1 in the reference prompt.", }, ), "image2_picture_id": ( ["1", "2", "3", "4", "5", "6", "7", "8", "9"], { "default": "2", "tooltip": "Picture number to mention for image2 in the reference prompt.", }, ), "location_id": ( "STRING", { "default": "", "multiline": False, "tooltip": "Optional location ID string to include in the output text.", }, ), "name": ( "STRING", { "default": "", "multiline": False, "tooltip": "Location name used in the main reference sentences.", }, ), "alias": ( "STRING", { "default": "", "multiline": False, "tooltip": "Optional alternate name, label, or area name.", }, ), "description": ( "STRING", { "default": "", "multiline": True, "tooltip": "Persistent environment, layout, and atmosphere description.", }, ), "general": ( "STRING", { "default": "", "multiline": True, "tooltip": "Optional extra notes appended as the last sentence of the reference prompt.", }, ), } } def build_location_text( self, image1, image2, image1_picture_id, image2_picture_id, location_id, name, alias, description, general, ): text = _build_location_helper_text( image1_picture_id, image2_picture_id, location_id, name, alias, description, general, ) return (image1, image2, text) class DumasCharacterReferenceNode: 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 DumasH3PromptCuratorNode: DESCRIPTION = ( "Curate one MiniMax H3 prompt from an action textbox, anchor text, " "soundscape text, and up to nine structured references. References are " "compacted so only mentioned names, aliases, or explicit / " "tags are sent onward." ) RETURN_TYPES = ("STRING",) + ("IMAGE",) * _H3_PROMPT_REF_SLOTS + ("INT", "STRING") RETURN_NAMES = ( "prompt", "ref_image_1", "ref_image_2", "ref_image_3", "ref_image_4", "ref_image_5", "ref_image_6", "ref_image_7", "ref_image_8", "ref_image_9", "reference_count", "debug", ) FUNCTION = "curate_prompt" CATEGORY = "Dumas/MiniMax" @classmethod def INPUT_TYPES(cls): optional = { "anchor": ( "STRING", { "forceInput": True, "tooltip": "Optional anchor/style text, usually from Dumas Anchor Style.", }, ), "soundscape": ( "STRING", { "forceInput": True, "tooltip": "Optional soundscape text, usually from Dumas Soundscape Helper.", }, ), } for slot in range(1, _H3_PROMPT_REF_SLOTS + 1): optional[f"ref_{slot}"] = ( _REFERENCE_TYPE, { "tooltip": ( f"Optional structured reference {slot}. The curator only outputs " "it if the action prompt mentions its name/alias or an explicit " f"/ tag." ) }, ) return { "required": { "action_prompt": ( "STRING", { "default": "", "multiline": True, "tooltip": ( "Write the final shot action here using character/location names. " "Mention a reference by name, alias, , or to use it." ), }, ), "anatomy_guard": ( ["auto", "on", "off"], { "default": "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 " "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 = str(style_description or "").strip() if not text: 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, "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", "DumasH3PromptCurator": "Dumas H3 Prompt Curator", "DumasAnchorStyle": "Dumas Anchor Style", "DumasCharacterHelper": "Dumas Character Helper", "DumasLocationHelper": "Dumas Location Helper", "DumasH3CharacterHelper": "Dumas Character Helper", }