1907 lines
73 KiB
Python
1907 lines
73 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_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,
|
|
),
|
|
]
|
|
)
|
|
_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"<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 _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
|
|
|
|
|
|
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 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 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,
|
|
"DumasAnchorStyle": DumasAnchorStyleNode,
|
|
"DumasCharacterHelper": DumasCharacterReferenceNode,
|
|
"DumasH3CharacterHelper": DumasCharacterReferenceNode,
|
|
}
|
|
|
|
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",
|
|
"DumasAnchorStyle": "Dumas Anchor Style",
|
|
"DumasCharacterHelper": "Dumas Character Reference",
|
|
"DumasH3CharacterHelper": "Dumas Character Reference",
|
|
}
|