344 lines
14 KiB
Python
344 lines
14 KiB
Python
"""Lazy R42 still-image branch driven by IAMCCS Settings PRO and Prompter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import copy
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import torch
|
|
import folder_paths
|
|
from PIL import Image
|
|
|
|
|
|
def _settings_from_linx(value: Any) -> dict[str, Any]:
|
|
"""Find the named Settings PRO payload without depending on one bus layout."""
|
|
seen: set[int] = set()
|
|
|
|
def visit(item: Any) -> dict[str, Any] | None:
|
|
if isinstance(item, dict):
|
|
marker = id(item)
|
|
if marker in seen:
|
|
return None
|
|
seen.add(marker)
|
|
payload = item.get("iamccs_minimax_h3_settings")
|
|
if isinstance(payload, dict):
|
|
settings = payload.get("settings")
|
|
if isinstance(settings, dict):
|
|
return settings
|
|
if isinstance(item.get("settings"), dict) and (
|
|
str(item.get("schema", "")).startswith("iamccs.minimax_h3")
|
|
or "task_mode" in item["settings"]
|
|
):
|
|
return item["settings"]
|
|
for child in item.values():
|
|
result = visit(child)
|
|
if result is not None:
|
|
return result
|
|
elif isinstance(item, (list, tuple)):
|
|
for child in item:
|
|
result = visit(child)
|
|
if result is not None:
|
|
return result
|
|
return None
|
|
|
|
return copy.deepcopy(visit(value) or {})
|
|
|
|
|
|
def _project(value: Any) -> dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
try:
|
|
parsed = json.loads(str(value or "{}"))
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _decode_visual(entry: Any) -> torch.Tensor | None:
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
encoded = entry.get("data") or entry.get("base64") or entry.get("data_url")
|
|
if not isinstance(encoded, str) or not encoded.strip():
|
|
return None
|
|
encoded = encoded.strip()
|
|
if encoded.startswith("data:"):
|
|
encoded = encoded.split(",", 1)[-1]
|
|
encoded += "=" * (-len(encoded) % 4)
|
|
try:
|
|
raw = base64.b64decode(encoded, validate=False)
|
|
image = Image.open(io.BytesIO(raw)).convert("RGB")
|
|
except Exception:
|
|
return None
|
|
array = np.asarray(image, dtype=np.float32) / 255.0
|
|
return torch.from_numpy(array).unsqueeze(0)
|
|
|
|
|
|
def _load_visual_path(entry: Any) -> torch.Tensor | None:
|
|
if not isinstance(entry, dict):
|
|
return None
|
|
raw = str(entry.get("path") or "").strip()
|
|
if not raw:
|
|
return None
|
|
input_root = Path(folder_paths.get_input_directory()).resolve()
|
|
try:
|
|
annotated = folder_paths.get_annotated_filepath(raw)
|
|
except Exception:
|
|
annotated = None
|
|
candidate = Path(annotated).resolve() if annotated else (input_root / raw).resolve()
|
|
try:
|
|
candidate.relative_to(input_root)
|
|
except ValueError:
|
|
return None
|
|
if not candidate.is_file():
|
|
return None
|
|
try:
|
|
image = Image.open(candidate).convert("RGB")
|
|
except Exception:
|
|
return None
|
|
array = np.asarray(image, dtype=np.float32) / 255.0
|
|
return torch.from_numpy(array).unsqueeze(0)
|
|
|
|
|
|
def _prompter_references(project_data: Any) -> list[torch.Tensor]:
|
|
project = _project(project_data)
|
|
visuals = project.get("ai_visual_files") or project.get("visual_files") or []
|
|
if not isinstance(visuals, list):
|
|
return []
|
|
result: list[torch.Tensor] = []
|
|
for visual in visuals:
|
|
image = _decode_visual(visual)
|
|
if image is None:
|
|
image = _load_visual_path(visual)
|
|
if image is not None:
|
|
result.append(image)
|
|
if len(result) >= 4:
|
|
break
|
|
return result
|
|
|
|
|
|
def _task_mode(cine_linx: Any) -> str:
|
|
settings = _settings_from_linx(cine_linx)
|
|
return str(settings.get("task_mode") or "").strip().lower()
|
|
|
|
|
|
class IAMCCS_R42ReferenceImagePromptBridge:
|
|
"""Resolve the Sato prompt, exact 4K canvas and selected reference route."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"prompt": ("STRING", {"default": "", "multiline": True, "forceInput": True}),
|
|
"project_json": ("STRING", {"default": "{}", "forceInput": True}),
|
|
"cine_linx": ("IAMCCS_SUPERNODE_LINX",),
|
|
},
|
|
"optional": {"direct_reference": ("IMAGE", {"lazy": True})},
|
|
}
|
|
|
|
RETURN_TYPES = (
|
|
"STRING", "INT", "INT", "IMAGE", "BOOLEAN", "STRING", "FLOAT", "STRING",
|
|
"IMAGE", "IMAGE", "IMAGE", "STRING",
|
|
)
|
|
RETURN_NAMES = (
|
|
"h3_prompt", "width", "height", "reference_image",
|
|
"detail_enabled", "detail_model", "detail_strength", "branch_report",
|
|
"reference_image_2", "reference_image_3", "reference_image_4", "vram_profile",
|
|
)
|
|
FUNCTION = "adapt"
|
|
CATEGORY = "IAMCCS/MiniMax H3/Reference"
|
|
|
|
def check_lazy_status(self, prompt, project_json, cine_linx, direct_reference=None):
|
|
settings = _settings_from_linx(cine_linx)
|
|
source = str(settings.get("h3_reference_image_source") or "auto_prompter_then_direct")
|
|
embedded = _prompter_references(project_json)
|
|
if source == "direct_reference" and direct_reference is None:
|
|
return ["direct_reference"]
|
|
# AUTO also supports a text-only H3 still. Requiring the optional
|
|
# socket here made PAN evaluate a stale LoadImage even when the
|
|
# Prompter project deliberately contained no visual reference.
|
|
return []
|
|
|
|
def adapt(self, prompt, project_json, cine_linx, direct_reference=None):
|
|
settings = _settings_from_linx(cine_linx)
|
|
project = _project(project_json)
|
|
task = str(settings.get("task_mode") or "").strip().lower()
|
|
if task != "reference_image":
|
|
raise ValueError(
|
|
"R42 H3 Image Generator is controlled by IAMCCS Settings PRO: "
|
|
f"select task_mode=reference_image (current={task or 'missing'})."
|
|
)
|
|
text = str(prompt or "").strip()
|
|
if not text:
|
|
raise ValueError("R42 H3 Image Generator: IAMCCS Prompter returned an empty final prompt.")
|
|
|
|
preset = str(settings.get("h3_reference_image_preset") or "single_image")
|
|
project_preset = str(project.get("image_preset") or "single_image").strip().lower()
|
|
if project_preset == "reference_sheet":
|
|
project_preset = "character_sheet"
|
|
if project_preset not in {"single_image", "character_sheet"}:
|
|
project_preset = "single_image"
|
|
if project_preset != preset:
|
|
raise ValueError(
|
|
"R42 H3 Image Generator: conflicting image presets. "
|
|
f"Prompter={project_preset}, Settings PRO={preset}. "
|
|
"Choose the same preset in both controls, run AI again, then press Inject."
|
|
)
|
|
request = str(project.get("request") or "").strip()
|
|
if not request and preset != "character_sheet":
|
|
raise ValueError(
|
|
"R42 H3 Image Generator: the Prompter request is empty. "
|
|
"Enter what image to create, run AI, then press Inject; cached analysis alone is not render authority."
|
|
)
|
|
if preset == "character_sheet":
|
|
suffix = (
|
|
"One static character reference sheet on a single canvas: clearly separated, non-overlapping "
|
|
"identity-consistent views with readable face, silhouette, wardrobe and material detail."
|
|
)
|
|
else:
|
|
suffix = (
|
|
"Create exactly one full-frame static production image. Ignore any earlier request for a reference "
|
|
"sheet, contact sheet, split screen, grid, panels, multiple views or montage. Do not add borders or "
|
|
"separators. The result must be one continuous scene filling the entire canvas, with no temporal "
|
|
"action, multi-shot sequence or motion language."
|
|
)
|
|
if suffix.lower() not in text.lower():
|
|
text = f"{text}\n\nOUTPUT CONTRACT:\n{suffix}"
|
|
|
|
width = max(64, int(settings.get("width") or 3840))
|
|
height = max(64, int(settings.get("height") or 2160))
|
|
source = str(settings.get("h3_reference_image_source") or "auto_prompter_then_direct")
|
|
embedded = _prompter_references(project_json)
|
|
if source == "prompter_ref2v":
|
|
references = embedded
|
|
if not references:
|
|
raise ValueError(
|
|
"Settings PRO selected PROMPTER REF2V, but IAMCCS Prompter has no attached visual. "
|
|
"Attach an image in Prompter, run AI, then Inject."
|
|
)
|
|
route = "prompter_ref2v+inject"
|
|
elif source == "direct_reference":
|
|
references = [direct_reference] if direct_reference is not None else []
|
|
if not references:
|
|
raise ValueError("Settings PRO selected DIRECT REFERENCE, but the direct image socket is empty.")
|
|
route = "direct_reference"
|
|
else:
|
|
references = embedded if embedded else ([direct_reference] if direct_reference is not None else [])
|
|
route = "prompter_ref2v+inject" if embedded else (
|
|
"direct_reference" if direct_reference is not None else "text_only"
|
|
)
|
|
|
|
detail_enabled = bool(settings.get("h3_reference_image_detail_enabled", False))
|
|
detail_model = str(settings.get("h3_reference_image_detail_model") or "None")
|
|
if not detail_enabled:
|
|
detail_model = "None"
|
|
detail_strength = float(settings.get("h3_reference_image_detail_strength", 0.60))
|
|
report = (
|
|
f"R42 Universal H3 image | Settings PRO mode={task} | SatoDiveH3Image | "
|
|
f"preset={preset} | exact={width}x{height} | reference={route} | "
|
|
f"detail={'off' if detail_model == 'None' else detail_model}"
|
|
)
|
|
padded = (references + [None, None, None, None])[:4]
|
|
profile = str(settings.get("h3_reference_image_vram_profile") or "up_to_12gb_safe")
|
|
return text, width, height, padded[0], detail_enabled, detail_model, detail_strength, report, padded[1], padded[2], padded[3], profile
|
|
|
|
|
|
class IAMCCS_H3OptionalSameSizeDetail:
|
|
"""Optional ESRGAN-style detail pass that returns to the exact input canvas."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {"required": {
|
|
"image": ("IMAGE",),
|
|
"enabled": ("BOOLEAN", {"forceInput": True}),
|
|
"model_name": ("STRING", {"forceInput": True}),
|
|
"strength": ("FLOAT", {"forceInput": True}),
|
|
}}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("image", "report")
|
|
FUNCTION = "apply"
|
|
CATEGORY = "IAMCCS/MiniMax H3/Reference"
|
|
|
|
def apply(self, image, enabled, model_name, strength):
|
|
name = str(model_name or "None")
|
|
mix = max(0.0, min(1.0, float(strength)))
|
|
if not enabled or name.lower() == "none" or mix <= 0.0:
|
|
return image, "H3 image detail pass OFF · native Sato pixels preserved"
|
|
from comfy_extras import nodes_upscale_model as upscale_nodes
|
|
import comfy.utils
|
|
|
|
model = upscale_nodes.UpscaleModelLoader().load_model(name)[0]
|
|
detailed = upscale_nodes.ImageUpscaleWithModel().upscale(model, image)[0]
|
|
_, height, width, _ = image.shape
|
|
detailed = comfy.utils.common_upscale(
|
|
detailed.movedim(-1, 1), width, height, "lanczos", "disabled"
|
|
).movedim(1, -1)
|
|
output = torch.lerp(image, detailed.to(device=image.device, dtype=image.dtype), mix).clamp(0.0, 1.0)
|
|
return output, f"H3 same-size detail · {name} · strength={mix:.2f} · canvas={width}x{height}"
|
|
|
|
|
|
class IAMCCS_H3ReferenceImageSaveR42:
|
|
"""Lazy output: request Sato only in Settings PRO reference_image mode."""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"cine_linx": ("IAMCCS_SUPERNODE_LINX",),
|
|
"filename_prefix": ("STRING", {"default": "IAMCCS/H3_REFERENCES/reference"}),
|
|
},
|
|
"optional": {"images": ("IMAGE", {"lazy": True})},
|
|
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("reference_image", "report")
|
|
FUNCTION = "save"
|
|
OUTPUT_NODE = True
|
|
CATEGORY = "IAMCCS/MiniMax H3/Reference"
|
|
|
|
def check_lazy_status(self, cine_linx, filename_prefix, images=None, **kwargs):
|
|
if _task_mode(cine_linx) == "reference_image" and images is None:
|
|
return ["images"]
|
|
return []
|
|
|
|
def save(self, cine_linx, filename_prefix, images=None, prompt=None, extra_pnginfo=None):
|
|
task = _task_mode(cine_linx)
|
|
if task != "reference_image":
|
|
return {"ui": {}, "result": (None, f"R42 image branch idle · Settings PRO mode={task or 'missing'}")}
|
|
if images is None or not torch.is_tensor(images) or len(images) < 1:
|
|
raise ValueError("R42 H3 Image Generator produced no image tensor.")
|
|
from nodes import SaveImage
|
|
|
|
image = images[:1]
|
|
saved = SaveImage().save_images(image, filename_prefix, prompt, extra_pnginfo)
|
|
report = (
|
|
f"H3 reference PNG | true R42 Universal contract | SatoDive | "
|
|
f"exact={int(image.shape[2])}x{int(image.shape[1])}"
|
|
)
|
|
return {"ui": saved["ui"], "result": (image, report)}
|
|
|
|
|
|
def reference_image_plan(plan, prompt):
|
|
"""Compatibility helper retained for older tests and serialized workflows."""
|
|
if not str(prompt).strip():
|
|
raise ValueError("Reference Image: enter a global description of the image to generate.")
|
|
result = copy.deepcopy(plan)
|
|
result.update(task_mode="reference_image", requested_task_mode="reference_image")
|
|
# The ShotPlanner enriches this contract with the Prompter/Shotboard paths
|
|
# after the common video plan has been built. Older R42 plans do not have
|
|
# this namespace, so always create it here instead of making the caller
|
|
# depend on a particular serialized workflow revision.
|
|
result["reference_image"] = {
|
|
"source_policy": "prompter_ref2v_then_references",
|
|
"prompter_paths": [],
|
|
"fallback_paths": [],
|
|
"prompt": str(prompt).strip(),
|
|
}
|
|
return result
|