3146 lines
131 KiB
Python
3146 lines
131 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
from .health import build_repro_lock
|
|
|
|
|
|
CATEGORY = "genasset"
|
|
FilePart = tuple[str, bytes, str]
|
|
MAX_CATALOG_BYTES = 8 * 1024 * 1024
|
|
CATALOG_ID_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,120}$")
|
|
RAW_GITHUB_PYPROJECT_URL = "https://raw.githubusercontent.com/steliosot/ComfyUI-GenAsset/main/pyproject.toml"
|
|
GENASSET_MANAGER_PACKAGE_ID = "genasset"
|
|
_GENASSET_UPDATE_LOCK = threading.Lock()
|
|
|
|
|
|
class GenAssetAnyType(str):
|
|
def __ne__(self, value: object) -> bool:
|
|
return False
|
|
|
|
|
|
GENASSET_ANY = GenAssetAnyType("*")
|
|
|
|
|
|
def _ensure_image_batch(image: torch.Tensor) -> torch.Tensor:
|
|
if image.dim() == 3:
|
|
image = image.unsqueeze(0)
|
|
if image.dim() != 4:
|
|
raise ValueError(f"Expected IMAGE tensor [B,H,W,C], got {tuple(image.shape)}")
|
|
return image
|
|
|
|
|
|
def tensor_to_png_bytes(image: torch.Tensor) -> bytes:
|
|
arr = image_to_rgb_array(image)
|
|
arr = np.nan_to_num(arr, nan=0.0, posinf=1.0, neginf=0.0)
|
|
pil = Image.fromarray((arr * 255.0).round().astype(np.uint8), mode="RGB")
|
|
buffer = io.BytesIO()
|
|
pil.save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
def image_to_rgb_array(image: torch.Tensor) -> np.ndarray:
|
|
image = _ensure_image_batch(image)
|
|
arr = image[0].detach().cpu().clamp(0, 1).numpy()
|
|
if arr.shape[-1] == 1:
|
|
arr = np.repeat(arr, 3, axis=-1)
|
|
elif arr.shape[-1] == 2:
|
|
arr = np.repeat(arr[..., :1], 3, axis=-1)
|
|
elif arr.shape[-1] >= 4:
|
|
rgb = arr[..., :3]
|
|
alpha = arr[..., 3:4]
|
|
arr = rgb * alpha + (1.0 - alpha)
|
|
elif arr.shape[-1] != 3:
|
|
raise ValueError(f"Expected IMAGE channels 1, 3, or 4; got {arr.shape[-1]}")
|
|
return arr[..., :3]
|
|
|
|
|
|
def image_quality_metrics(image: torch.Tensor) -> dict[str, Any]:
|
|
arr = image_to_rgb_array(image)
|
|
luma = (0.2126 * arr[..., 0]) + (0.7152 * arr[..., 1]) + (0.0722 * arr[..., 2])
|
|
mean = float(np.mean(luma))
|
|
std = float(np.std(luma))
|
|
p95 = float(np.percentile(luma, 95))
|
|
p99 = float(np.percentile(luma, 99))
|
|
non_black_fraction = float(np.mean(luma > 0.04))
|
|
dynamic_range = float(np.max(luma) - np.min(luma))
|
|
rejected = p99 < 0.04 or (mean < 0.025 and non_black_fraction < 0.02) or (std < 0.005 and mean < 0.04)
|
|
return {
|
|
"mean_luma": round(mean, 6),
|
|
"std_luma": round(std, 6),
|
|
"p95_luma": round(p95, 6),
|
|
"p99_luma": round(p99, 6),
|
|
"non_black_fraction": round(non_black_fraction, 6),
|
|
"dynamic_range": round(dynamic_range, 6),
|
|
"blank_or_black_rejected": rejected,
|
|
"rule": "reject if p99<0.04, or mean<0.025 with <2% non-black pixels, or near-flat mean<0.04",
|
|
}
|
|
|
|
|
|
def pil_to_tensor(image: Image.Image) -> torch.Tensor:
|
|
arr = np.asarray(image.convert("RGB")).astype(np.float32) / 255.0
|
|
return torch.from_numpy(arr).unsqueeze(0)
|
|
|
|
|
|
def blank_image() -> torch.Tensor:
|
|
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
|
|
|
|
|
def parse_json(value: str, fallback: Any) -> Any:
|
|
if not value:
|
|
return fallback
|
|
try:
|
|
return json.loads(value)
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
def as_node_id(value: Any) -> str | None:
|
|
if isinstance(value, (list, tuple)) and value:
|
|
return str(value[0])
|
|
if isinstance(value, str):
|
|
return value
|
|
return None
|
|
|
|
|
|
def input_link(node: dict[str, Any], name: str) -> str | None:
|
|
inputs = node.get("inputs") if isinstance(node, dict) else {}
|
|
if not isinstance(inputs, dict):
|
|
return None
|
|
return as_node_id(inputs.get(name))
|
|
|
|
|
|
def workflow_from_extra(extra_pnginfo: Any) -> dict[str, Any]:
|
|
if isinstance(extra_pnginfo, dict):
|
|
workflow = extra_pnginfo.get("workflow")
|
|
return workflow if isinstance(workflow, dict) else {}
|
|
return {}
|
|
|
|
|
|
def api_prompt_from_hidden(prompt: Any) -> dict[str, Any]:
|
|
if isinstance(prompt, dict):
|
|
return prompt
|
|
return {}
|
|
|
|
|
|
def node_class(node: dict[str, Any] | None) -> str:
|
|
if not isinstance(node, dict):
|
|
return ""
|
|
return str(node.get("class_type") or node.get("type") or "")
|
|
|
|
|
|
def walk_upstream(api_prompt: dict[str, Any], start_id: str | None, limit: int = 80) -> list[str]:
|
|
if not start_id:
|
|
return []
|
|
seen: set[str] = set()
|
|
queue = [str(start_id)]
|
|
ordered: list[str] = []
|
|
while queue and len(ordered) < limit:
|
|
node_id = queue.pop(0)
|
|
if node_id in seen:
|
|
continue
|
|
seen.add(node_id)
|
|
node = api_prompt.get(node_id)
|
|
if not isinstance(node, dict):
|
|
continue
|
|
ordered.append(node_id)
|
|
inputs = node.get("inputs")
|
|
if not isinstance(inputs, dict):
|
|
continue
|
|
for value in inputs.values():
|
|
upstream = as_node_id(value)
|
|
if upstream and upstream not in seen:
|
|
queue.append(upstream)
|
|
return ordered
|
|
|
|
|
|
def find_nodes_by_class(api_prompt: dict[str, Any], node_ids: list[str], class_names: set[str]) -> list[tuple[str, dict[str, Any]]]:
|
|
out = []
|
|
for node_id in node_ids:
|
|
node = api_prompt.get(node_id)
|
|
if isinstance(node, dict) and node_class(node) in class_names:
|
|
out.append((node_id, node))
|
|
return out
|
|
|
|
|
|
def find_first_node_by_class(api_prompt: dict[str, Any], node_ids: list[str], class_names: set[str]) -> tuple[str, dict[str, Any]] | tuple[None, None]:
|
|
matches = find_nodes_by_class(api_prompt, node_ids, class_names)
|
|
return matches[0] if matches else (None, None)
|
|
|
|
|
|
def collect_prompt_texts(api_prompt: dict[str, Any], sampler: dict[str, Any] | None, upstream_ids: list[str]) -> tuple[str, str]:
|
|
positive_text = ""
|
|
negative_text = ""
|
|
if sampler:
|
|
positive_id = input_link(sampler, "positive")
|
|
negative_id = input_link(sampler, "negative")
|
|
positive_text = text_from_condition(api_prompt, positive_id)
|
|
negative_text = text_from_condition(api_prompt, negative_id, negative=True)
|
|
|
|
if positive_text or negative_text:
|
|
return positive_text, negative_text
|
|
|
|
clip_nodes = find_nodes_by_class(
|
|
api_prompt,
|
|
upstream_ids,
|
|
{
|
|
"CLIPTextEncode",
|
|
"CLIPTextEncodeSDXL",
|
|
"BNK_CLIPTextEncodeAdvanced",
|
|
"TextEncodeQwenImageEdit",
|
|
"TextEncodeQwenImageEditPlus",
|
|
"TextEncodeZImageOmni",
|
|
},
|
|
)
|
|
texts = []
|
|
for _, node in clip_nodes:
|
|
text = prompt_text_from_inputs(node.get("inputs"))
|
|
if text:
|
|
texts.append(text)
|
|
if texts:
|
|
positive_text = texts[0]
|
|
if len(texts) > 1:
|
|
negative_text = texts[1]
|
|
if positive_text or negative_text:
|
|
return positive_text, negative_text
|
|
return collect_prompt_texts_from_inputs(api_prompt, upstream_ids)
|
|
|
|
|
|
def prompt_text_from_inputs(inputs: Any) -> str:
|
|
if not isinstance(inputs, dict):
|
|
return ""
|
|
for key in ("prompt", "positive", "text", "user_prompt", "prompt_text", "caption", "instruction", "instructions"):
|
|
value = inputs.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
parts = []
|
|
for key in ("text_g", "text_l", "clip_l", "clip_g", "t5xxl", "llama"):
|
|
value = inputs.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
parts.append(value.strip())
|
|
return " ".join(dict.fromkeys(parts)).strip()
|
|
|
|
|
|
def negative_prompt_text_from_inputs(inputs: Any) -> str:
|
|
if not isinstance(inputs, dict):
|
|
return ""
|
|
for key in ("negative", "negative_prompt", "negative_prompt_text", "text_neg", "text_neg_g", "text_neg_l"):
|
|
value = inputs.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return ""
|
|
|
|
|
|
def collect_prompt_texts_from_inputs(api_prompt: dict[str, Any], upstream_ids: list[str]) -> tuple[str, str]:
|
|
positive_text = ""
|
|
negative_text = ""
|
|
for node_id in upstream_ids:
|
|
node = api_prompt.get(node_id)
|
|
if not isinstance(node, dict):
|
|
continue
|
|
inputs = node.get("inputs")
|
|
if not positive_text:
|
|
positive_text = prompt_text_from_inputs(inputs)
|
|
if not negative_text:
|
|
negative_text = negative_prompt_text_from_inputs(inputs)
|
|
if positive_text and negative_text:
|
|
break
|
|
return positive_text, negative_text
|
|
|
|
|
|
def text_from_condition(api_prompt: dict[str, Any], node_id: str | None, negative: bool = False) -> str:
|
|
if not node_id:
|
|
return ""
|
|
node = api_prompt.get(str(node_id))
|
|
if not isinstance(node, dict):
|
|
return ""
|
|
if negative and node_class(node) in {"ConditioningZeroOut"}:
|
|
return ""
|
|
inputs = node.get("inputs")
|
|
text = prompt_text_from_inputs(inputs)
|
|
if text:
|
|
return text
|
|
for upstream in walk_upstream(api_prompt, str(node_id), limit=20):
|
|
upstream_node = api_prompt.get(upstream)
|
|
if not isinstance(upstream_node, dict):
|
|
continue
|
|
if negative and node_class(upstream_node) in {"ConditioningZeroOut"}:
|
|
return ""
|
|
upstream_inputs = upstream_node.get("inputs")
|
|
text = prompt_text_from_inputs(upstream_inputs)
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
|
|
def collect_checkpoint_name(api_prompt: dict[str, Any], upstream_ids: list[str]) -> str:
|
|
for _, node in find_nodes_by_class(
|
|
api_prompt,
|
|
upstream_ids,
|
|
{"CheckpointLoaderSimple", "CheckpointLoader", "UNETLoader", "DiffusersLoader", "VAELoader"},
|
|
):
|
|
inputs = node.get("inputs") if isinstance(node, dict) else {}
|
|
if not isinstance(inputs, dict):
|
|
continue
|
|
for key in ("ckpt_name", "checkpoint", "unet_name", "model_name", "vae_name"):
|
|
value = inputs.get(key)
|
|
if isinstance(value, str) and value.strip():
|
|
return value.strip()
|
|
return ""
|
|
|
|
|
|
def collect_sampler_metadata(sampler: dict[str, Any] | None) -> dict[str, Any]:
|
|
inputs = sampler.get("inputs") if isinstance(sampler, dict) else {}
|
|
if not isinstance(inputs, dict):
|
|
return {}
|
|
keys = ("seed", "steps", "cfg", "sampler_name", "scheduler", "denoise", "start_at_step", "end_at_step", "add_noise")
|
|
return {key: inputs[key] for key in keys if key in inputs and not isinstance(inputs[key], (list, tuple))}
|
|
|
|
|
|
def collect_latent_metadata(api_prompt: dict[str, Any], upstream_ids: list[str]) -> dict[str, Any]:
|
|
for _, node in find_nodes_by_class(api_prompt, upstream_ids, {"EmptyLatentImage", "EmptySD3LatentImage"}):
|
|
inputs = node.get("inputs") if isinstance(node, dict) else {}
|
|
if not isinstance(inputs, dict):
|
|
continue
|
|
out = {}
|
|
for key in ("width", "height", "batch_size"):
|
|
if key in inputs:
|
|
out[key] = inputs[key]
|
|
if out:
|
|
return out
|
|
return {}
|
|
|
|
|
|
def comfy_input_image_path(image_name: str) -> Path | None:
|
|
if not image_name.strip():
|
|
return None
|
|
try:
|
|
import folder_paths # type: ignore
|
|
|
|
path = Path(folder_paths.get_annotated_filepath(image_name))
|
|
if path.is_file():
|
|
return path
|
|
except Exception:
|
|
pass
|
|
|
|
for base in (Path.cwd() / "input", Path.cwd()):
|
|
path = base / image_name
|
|
if path.is_file():
|
|
return path
|
|
return None
|
|
|
|
|
|
def collect_input_image_files(api_prompt: dict[str, Any], upstream_ids: list[str]) -> list[tuple[str, FilePart]]:
|
|
files: list[tuple[str, FilePart]] = []
|
|
seen: set[str] = set()
|
|
for node_id, node in find_nodes_by_class(api_prompt, upstream_ids, {"LoadImage"}):
|
|
inputs = node.get("inputs") if isinstance(node, dict) else {}
|
|
image_name = pick_string(inputs.get("image") if isinstance(inputs, dict) else "")
|
|
if not image_name or image_name in seen:
|
|
continue
|
|
seen.add(image_name)
|
|
path = comfy_input_image_path(image_name)
|
|
if not path:
|
|
_log_error("SaveToGenAsset", f"Input image not found for upload: {image_name}")
|
|
continue
|
|
content_type = mimetypes.guess_type(path.name)[0] or "image/png"
|
|
if content_type not in {"image/png", "image/jpeg", "image/webp"}:
|
|
_log_error("SaveToGenAsset", f"Skipping unsupported input image type: {image_name}")
|
|
continue
|
|
try:
|
|
files.append(("input_images", (path.name, path.read_bytes(), content_type)))
|
|
_log_ok("SaveToGenAsset", input_image=path.name, input_node=node_id)
|
|
except Exception as exc:
|
|
_log_error("SaveToGenAsset", f"Could not read input image {image_name}: {exc}")
|
|
return files
|
|
|
|
|
|
def redact_secret_fields(value: Any) -> Any:
|
|
if isinstance(value, dict):
|
|
out = {}
|
|
for key, item in value.items():
|
|
key_text = str(key).lower()
|
|
if key_text in {"workspace_token", "token", "authorization", "api_key", "secret", "service_role_key"}:
|
|
out[key] = "[redacted]"
|
|
else:
|
|
out[key] = redact_secret_fields(item)
|
|
return out
|
|
if isinstance(value, list):
|
|
return [redact_secret_fields(item) for item in value]
|
|
return value
|
|
|
|
|
|
def derive_tags(prompt: str, model: str) -> list[str]:
|
|
tags: list[str] = []
|
|
lower = prompt.lower()
|
|
for label in (
|
|
"cinematic",
|
|
"london",
|
|
"widescreen",
|
|
"portrait",
|
|
"woman",
|
|
"man",
|
|
"robot",
|
|
"rain",
|
|
"spacecraft",
|
|
"landscape",
|
|
"house",
|
|
"car",
|
|
"product",
|
|
"sdxl",
|
|
"sd1.5",
|
|
):
|
|
if re.search(rf"(?<![a-z0-9]){re.escape(label)}(?![a-z0-9])", lower) and label not in tags:
|
|
tags.append(label)
|
|
model_lower = model.lower()
|
|
if "xl" in model_lower and "sdxl" not in tags:
|
|
tags.append("sdxl")
|
|
if "realistic" in model_lower and "realistic" not in tags:
|
|
tags.append("realistic")
|
|
return tags[:8]
|
|
|
|
|
|
def generation_family(model: str) -> str:
|
|
lower = model.lower()
|
|
if "xl" in lower:
|
|
return "sdxl"
|
|
if "v1-5" in lower or "sd1" in lower or "realistic_vision" in lower:
|
|
return "sd1.5"
|
|
return ""
|
|
|
|
|
|
def auto_capture_generation(
|
|
image: torch.Tensor,
|
|
api_prompt: dict[str, Any],
|
|
extra_pnginfo: Any,
|
|
unique_id: Any,
|
|
asset_name: str,
|
|
) -> dict[str, Any]:
|
|
uid = str(unique_id[0] if isinstance(unique_id, (list, tuple)) and unique_id else unique_id or "")
|
|
save_node = api_prompt.get(uid) if uid else None
|
|
image_source_id = input_link(save_node, "image") if isinstance(save_node, dict) else None
|
|
upstream_ids = walk_upstream(api_prompt, image_source_id)
|
|
sampler_id, sampler = find_first_node_by_class(
|
|
api_prompt,
|
|
upstream_ids,
|
|
{"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"},
|
|
)
|
|
positive_prompt, negative_prompt = collect_prompt_texts(api_prompt, sampler, upstream_ids)
|
|
sampler_metadata = collect_sampler_metadata(sampler)
|
|
model = collect_checkpoint_name(api_prompt, upstream_ids)
|
|
latent = collect_latent_metadata(api_prompt, upstream_ids)
|
|
workflow = workflow_from_extra(extra_pnginfo)
|
|
image_batch = _ensure_image_batch(image)
|
|
height = int(image_batch.shape[1])
|
|
width = int(image_batch.shape[2])
|
|
seed = sampler_metadata.get("seed", 0)
|
|
metadata = {
|
|
"source": "comfyui-auto-capture",
|
|
"asset_name": asset_name,
|
|
"workflow": {
|
|
"name": asset_name,
|
|
"kind": "txt2img-from-comfyui",
|
|
"family": generation_family(model),
|
|
},
|
|
"capture": {
|
|
"node_id": uid,
|
|
"image_source_node_id": image_source_id,
|
|
"upstream_node_ids": upstream_ids,
|
|
},
|
|
"performance": sampler_metadata,
|
|
"model": {
|
|
"checkpoint": model,
|
|
},
|
|
"image": {
|
|
"width": width,
|
|
"height": height,
|
|
"batch_size": int(image_batch.shape[0]),
|
|
},
|
|
"latent": latent,
|
|
}
|
|
workflow_json = {
|
|
"api_prompt": redact_secret_fields(api_prompt),
|
|
"workflow": redact_secret_fields(workflow),
|
|
"captured_from_node_id": uid,
|
|
"captured_upstream_node_ids": upstream_ids,
|
|
}
|
|
return {
|
|
"prompt": positive_prompt,
|
|
"negative_prompt": negative_prompt,
|
|
"workflow_json": workflow_json,
|
|
"model": model,
|
|
"seed": int(seed) if isinstance(seed, int) or str(seed).isdigit() else 0,
|
|
"metadata": metadata,
|
|
"tags": derive_tags(positive_prompt, model),
|
|
"sampler_id": sampler_id,
|
|
}
|
|
|
|
|
|
def multipart_file_items(files: dict[str, FilePart] | list[tuple[str, FilePart]]) -> list[tuple[str, FilePart]]:
|
|
if isinstance(files, dict):
|
|
return list(files.items())
|
|
return files
|
|
|
|
|
|
def post_multipart(url: str, token: str, fields: dict[str, str], files: dict[str, FilePart] | list[tuple[str, FilePart]]) -> dict[str, Any]:
|
|
boundary = f"----GenAssetBoundary{uuid.uuid4().hex}"
|
|
chunks: list[bytes] = []
|
|
|
|
for name, value in fields.items():
|
|
chunks.append(f"--{boundary}\r\n".encode())
|
|
chunks.append(f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode())
|
|
chunks.append(str(value).encode())
|
|
chunks.append(b"\r\n")
|
|
|
|
for name, (filename, data, content_type) in multipart_file_items(files):
|
|
safe_name = filename.replace('"', "")
|
|
chunks.append(f"--{boundary}\r\n".encode())
|
|
chunks.append(
|
|
f'Content-Disposition: form-data; name="{name}"; filename="{safe_name}"\r\n'.encode()
|
|
)
|
|
chunks.append(f"Content-Type: {content_type}\r\n\r\n".encode())
|
|
chunks.append(data)
|
|
chunks.append(b"\r\n")
|
|
|
|
chunks.append(f"--{boundary}--\r\n".encode())
|
|
body = b"".join(chunks)
|
|
request = urllib.request.Request(
|
|
url,
|
|
data=body,
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
},
|
|
method="POST",
|
|
)
|
|
return read_json(request)
|
|
|
|
|
|
def read_json(request: urllib.request.Request) -> dict[str, Any]:
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=120) as response:
|
|
payload = response.read().decode("utf-8")
|
|
return json.loads(payload)
|
|
except urllib.error.HTTPError as exc:
|
|
payload = exc.read().decode("utf-8", errors="replace")
|
|
try:
|
|
data = json.loads(payload)
|
|
except Exception:
|
|
data = {"error": friendly_non_json_error(request, payload, exc.code)}
|
|
raise RuntimeError(data.get("error") or f"HTTP {exc.code}") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise RuntimeError(f"Could not reach GenAsset: {exc.reason}") from exc
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(friendly_non_json_error(request, "", None)) from exc
|
|
|
|
|
|
def request_json(method: str, url: str, token: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
data = None
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
headers["Content-Type"] = "application/json"
|
|
request = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
|
|
return read_json(request)
|
|
|
|
|
|
def friendly_non_json_error(request: urllib.request.Request, payload: str, status_code: int | None) -> str:
|
|
url = getattr(request, "full_url", "")
|
|
if "api/v1/comfy/workflow-assist" in url:
|
|
base_url = url.split("/api/v1/comfy/workflow-assist", 1)[0]
|
|
if "<html" in payload.lower() or "<!doctype" in payload.lower() or status_code == 404:
|
|
return (
|
|
"GenAsset Workflow Assistant is not available on this GenAsset server yet. "
|
|
f"The ComfyUI node is installed, but {base_url}/api/v1/comfy/workflow-assist "
|
|
"did not return the assistant API. Deploy the latest GenAsset app, or continue "
|
|
"using Save To GenAsset manually."
|
|
)
|
|
return (
|
|
"GenAsset Workflow Assistant returned a non-JSON response. "
|
|
"Check that the GenAsset app is running the latest assistant API route."
|
|
)
|
|
return "GenAsset returned invalid JSON."
|
|
|
|
|
|
def _validate_catalog_workflow_id(workflow_id: str) -> str:
|
|
value = str(workflow_id or "").strip()
|
|
if not value or not CATALOG_ID_PATTERN.fullmatch(value):
|
|
raise RuntimeError("Invalid GenAsset workflow id.")
|
|
return value
|
|
|
|
|
|
def _read_public_json_url(url: str, max_bytes: int = MAX_CATALOG_BYTES) -> Any:
|
|
request = urllib.request.Request(url, headers={"User-Agent": "ComfyUI-GenAsset/1.0"})
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
length = response.headers.get("Content-Length")
|
|
if length:
|
|
try:
|
|
if int(length) > max_bytes:
|
|
raise RuntimeError("GenAsset workflow response is too large.")
|
|
except ValueError:
|
|
pass
|
|
payload = response.read(max_bytes + 1)
|
|
except urllib.error.HTTPError as exc:
|
|
payload = exc.read().decode("utf-8", errors="replace")
|
|
try:
|
|
data = json.loads(payload)
|
|
message = data.get("error") if isinstance(data, dict) else ""
|
|
except Exception:
|
|
message = ""
|
|
raise RuntimeError(message or f"GenAsset catalog returned HTTP {exc.code}.") from exc
|
|
except urllib.error.URLError as exc:
|
|
raise RuntimeError(f"Could not reach GenAsset catalog: {exc.reason}") from exc
|
|
|
|
if len(payload) > max_bytes:
|
|
raise RuntimeError("GenAsset workflow response is too large.")
|
|
try:
|
|
return json.loads(payload.decode("utf-8"))
|
|
except Exception as exc:
|
|
raise RuntimeError("GenAsset catalog returned invalid JSON.") from exc
|
|
|
|
|
|
def _is_visual_workflow(value: Any) -> bool:
|
|
return isinstance(value, dict) and isinstance(value.get("nodes"), list) and len(value.get("nodes") or []) > 0
|
|
|
|
|
|
def _normalize_import_workflow_payload(payload: Any) -> dict[str, Any]:
|
|
if _is_visual_workflow(payload):
|
|
return payload
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("GenAsset catalog response was not a workflow object.")
|
|
workflow = payload.get("workflow")
|
|
if _is_visual_workflow(workflow):
|
|
return workflow
|
|
workflow_json = payload.get("workflow_json")
|
|
if _is_visual_workflow(workflow_json):
|
|
return workflow_json
|
|
if isinstance(workflow_json, dict) and _is_visual_workflow(workflow_json.get("workflow")):
|
|
return workflow_json["workflow"]
|
|
raise RuntimeError("GenAsset catalog response did not include a visual ComfyUI workflow.")
|
|
|
|
|
|
def _genasset_catalog_base_url() -> str:
|
|
env_value = os.getenv("GENASSET_BASE_URL", "").strip().rstrip("/")
|
|
if env_value:
|
|
return env_value
|
|
try:
|
|
config, _ = _read_genasset_config()
|
|
configured = pick_string(config.get("base_url")).rstrip("/")
|
|
if configured:
|
|
return configured
|
|
except Exception:
|
|
pass
|
|
return DEFAULT_BASE_URL
|
|
|
|
|
|
def _catalog_url(path: str) -> str:
|
|
base_url = _genasset_catalog_base_url()
|
|
return urllib.parse.urljoin(base_url + "/", path.lstrip("/"))
|
|
|
|
|
|
def _slug_from_workflow_filename(file_name: str) -> str:
|
|
text = re.sub(r"\.json$", "", file_name, flags=re.IGNORECASE)
|
|
text = re.sub(r"^GenAsset-", "", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
|
|
return text or "workflow"
|
|
|
|
|
|
def _title_from_slug(slug: str) -> str:
|
|
acronyms = {"ai", "api", "ui", "sdxl"}
|
|
return " ".join(part.upper() if part in acronyms else part.capitalize() for part in slug.split("-") if part)
|
|
|
|
|
|
def _mounted_custom_workflow_dir() -> Path | None:
|
|
configured = os.getenv("GENASSET_WORKFLOW_CATALOG_DIR", "").strip()
|
|
candidates = []
|
|
if configured:
|
|
candidates.append(Path(configured))
|
|
candidates.extend(
|
|
[
|
|
package_root() / "workflows",
|
|
Path("/opt/ComfyUI/models/workflows/09_custom"),
|
|
Path("/opt/ComfyUI/models/workflows"),
|
|
]
|
|
)
|
|
for candidate in candidates:
|
|
if candidate.exists() and candidate.is_dir():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _mounted_workflow_files() -> list[Path]:
|
|
root = _mounted_custom_workflow_dir()
|
|
if root is None:
|
|
return []
|
|
return sorted(path for path in root.rglob("*.json") if path.is_file())
|
|
|
|
|
|
def _mounted_workflow_card(path: Path) -> dict[str, Any]:
|
|
slug = _slug_from_workflow_filename(path.name)
|
|
node_count = 0
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
workflow = _normalize_import_workflow_payload(payload)
|
|
node_count = len(workflow.get("nodes") or [])
|
|
except Exception:
|
|
pass
|
|
metadata_by_slug = {
|
|
"import-demo": {
|
|
"description": "Tiny no-model workflow for testing one-click GenAsset import into ComfyUI.",
|
|
"category": "Setup",
|
|
"needs_model": False,
|
|
"model_requirements": [],
|
|
},
|
|
"simple-text-to-image-save": {
|
|
"description": "Tiny text-to-image workflow that saves the result to GenAsset.",
|
|
"category": "Starter",
|
|
"needs_model": True,
|
|
"model_requirements": ["Juggernaut_X_RunDiffusion.safetensors"],
|
|
},
|
|
"simple-text-to-image-ai-save": {
|
|
"description": "Tiny text-to-image workflow where GenAsset Workflow Assistant fills save fields before saving.",
|
|
"category": "Starter",
|
|
"needs_model": True,
|
|
"model_requirements": ["Juggernaut_X_RunDiffusion.safetensors"],
|
|
},
|
|
}
|
|
metadata = metadata_by_slug.get(
|
|
slug,
|
|
{
|
|
"description": "Ready-to-import GenAsset workflow.",
|
|
"category": "Workflow",
|
|
"needs_model": False,
|
|
"model_requirements": [],
|
|
},
|
|
)
|
|
return {
|
|
"id": slug,
|
|
"title": _title_from_slug(slug),
|
|
"description": metadata["description"],
|
|
"category": metadata["category"],
|
|
"level": "Beginner",
|
|
"node_count": node_count,
|
|
"needs_model": metadata["needs_model"],
|
|
"model_requirements": metadata["model_requirements"],
|
|
"tags": ["custom"],
|
|
}
|
|
|
|
|
|
def _mounted_public_workflow_catalog() -> dict[str, Any]:
|
|
workflows = [_mounted_workflow_card(path) for path in _mounted_workflow_files()]
|
|
if not workflows:
|
|
raise RuntimeError("No mounted GenAsset workflows found.")
|
|
return {"workflows": workflows}
|
|
|
|
|
|
def _mounted_public_import_workflow(workflow_id: str) -> dict[str, Any]:
|
|
clean_id = _validate_catalog_workflow_id(workflow_id)
|
|
for path in _mounted_workflow_files():
|
|
slug = _slug_from_workflow_filename(path.name)
|
|
if clean_id not in {slug, path.name, path.stem}:
|
|
continue
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
workflow = _normalize_import_workflow_payload(data)
|
|
return {
|
|
"ok": True,
|
|
"id": slug,
|
|
"title": _title_from_slug(slug),
|
|
"workflow": workflow,
|
|
"warnings": [],
|
|
"source": str(path),
|
|
}
|
|
raise RuntimeError("Workflow not found.")
|
|
|
|
|
|
def fetch_public_workflow_catalog() -> dict[str, Any]:
|
|
try:
|
|
data = _read_public_json_url(_catalog_url("/api/catalog/workflow-import"))
|
|
if not isinstance(data, dict) or not isinstance(data.get("workflows"), list):
|
|
raise RuntimeError("GenAsset catalog did not return a workflow list.")
|
|
return data
|
|
except Exception:
|
|
return _mounted_public_workflow_catalog()
|
|
|
|
|
|
def fetch_public_import_workflow(workflow_id: str) -> dict[str, Any]:
|
|
clean_id = _validate_catalog_workflow_id(workflow_id)
|
|
try:
|
|
data = _read_public_json_url(_catalog_url(f"/api/catalog/workflow-import/{urllib.parse.quote(clean_id)}"))
|
|
workflow = _normalize_import_workflow_payload(data)
|
|
title = clean_id
|
|
warnings: list[str] = []
|
|
if isinstance(data, dict):
|
|
title = pick_string(data.get("title"), data.get("id"), clean_id)
|
|
raw_warnings = data.get("warnings")
|
|
if isinstance(raw_warnings, list):
|
|
warnings = [str(item) for item in raw_warnings if str(item).strip()]
|
|
return {"ok": True, "id": clean_id, "title": title, "workflow": workflow, "warnings": warnings}
|
|
except Exception:
|
|
return _mounted_public_import_workflow(clean_id)
|
|
|
|
|
|
try:
|
|
from aiohttp import web # type: ignore
|
|
from server import PromptServer # type: ignore
|
|
|
|
@PromptServer.instance.routes.get("/genasset/catalog/workflows")
|
|
async def genasset_catalog_workflows_route(request): # type: ignore[no-untyped-def]
|
|
try:
|
|
return web.json_response(fetch_public_workflow_catalog())
|
|
except Exception as exc:
|
|
return web.json_response({"error": str(exc)}, status=502)
|
|
|
|
@PromptServer.instance.routes.get("/genasset/catalog/workflows/{workflow_id}")
|
|
async def genasset_catalog_workflow_route(request): # type: ignore[no-untyped-def]
|
|
try:
|
|
workflow_id = request.match_info.get("workflow_id", "")
|
|
return web.json_response(fetch_public_import_workflow(workflow_id))
|
|
except Exception as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def load_version_payload(base_url: str, workspace_token: str, version_id: str) -> dict[str, Any]:
|
|
path = f"api/v1/versions/{urllib.parse.quote(version_id)}/load"
|
|
url = urllib.parse.urljoin(base_url + "/", path)
|
|
return request_json("GET", url, workspace_token)
|
|
|
|
|
|
def extract_prompt_pair_from_api_prompt(api_prompt: dict[str, Any]) -> tuple[str, str]:
|
|
if not isinstance(api_prompt, dict):
|
|
return "", ""
|
|
node_ids = list(api_prompt.keys())
|
|
sampler_id, sampler = find_first_node_by_class(
|
|
api_prompt,
|
|
node_ids,
|
|
{"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"},
|
|
)
|
|
if sampler_id:
|
|
upstream_ids = walk_upstream(api_prompt, sampler_id)
|
|
else:
|
|
upstream_ids = node_ids
|
|
return collect_prompt_texts(api_prompt, sampler, upstream_ids)
|
|
|
|
|
|
def pick_dict(source: Any, key: str) -> dict[str, Any]:
|
|
if not isinstance(source, dict):
|
|
return {}
|
|
value = source.get(key)
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def pick_string(*values: Any) -> str:
|
|
for value in values:
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
|
|
def pick_int(*values: Any) -> int:
|
|
for value in values:
|
|
if isinstance(value, bool):
|
|
continue
|
|
if isinstance(value, int):
|
|
return int(value)
|
|
if isinstance(value, float):
|
|
return int(value)
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if text and re.fullmatch(r"-?\d+", text):
|
|
return int(text)
|
|
return 0
|
|
|
|
|
|
def pick_float(*values: Any) -> float:
|
|
for value in values:
|
|
if isinstance(value, bool):
|
|
continue
|
|
if isinstance(value, (int, float)):
|
|
return float(value)
|
|
if isinstance(value, str):
|
|
text = value.strip()
|
|
if not text:
|
|
continue
|
|
try:
|
|
return float(text)
|
|
except Exception:
|
|
continue
|
|
return 0.0
|
|
|
|
|
|
def download_image(url: str) -> Image.Image:
|
|
request = urllib.request.Request(url, headers={"User-Agent": "ComfyUI-GenAsset/0.1"})
|
|
with urllib.request.urlopen(request, timeout=120) as response:
|
|
data = response.read()
|
|
return Image.open(io.BytesIO(data)).convert("RGB")
|
|
|
|
|
|
def looks_like_uuid(value: str) -> bool:
|
|
return bool(re.fullmatch(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", value.strip()))
|
|
|
|
|
|
DEFAULT_BASE_URL = "https://genasset.xyz"
|
|
WORKSPACE_TOKEN_PLACEHOLDER = "PASTE_TOKEN"
|
|
GENASSET_CONFIG_FILENAME = "genasset.json"
|
|
TOKEN_FILE_HINT = "ComfyUI/user/genasset.json"
|
|
|
|
|
|
def _single_line(value: Any, limit: int = 220) -> str:
|
|
text = str(value or "").replace("\n", " ").replace("\r", " ").strip()
|
|
if len(text) > limit:
|
|
return text[: limit - 1] + "..."
|
|
return text
|
|
|
|
|
|
def _truncate_text(value: str, limit: int) -> tuple[str, bool]:
|
|
clean_limit = max(200, min(int(limit or 12000), 200000))
|
|
if len(value) <= clean_limit:
|
|
return value, False
|
|
suffix = f"\n\n... truncated {len(value) - clean_limit} characters ..."
|
|
return value[:clean_limit] + suffix, True
|
|
|
|
|
|
def _json_safe_value(value: Any, depth: int = 0) -> Any:
|
|
if depth > 6:
|
|
return f"<{type(value).__name__}: maximum display depth reached>"
|
|
if value is None or isinstance(value, (bool, int, float, str)):
|
|
return value
|
|
if isinstance(value, bytes):
|
|
return {"type": "bytes", "length": len(value), "preview": value[:96].decode("utf-8", errors="replace")}
|
|
if isinstance(value, Path):
|
|
return str(value)
|
|
if isinstance(value, torch.Tensor):
|
|
out: dict[str, Any] = {
|
|
"type": "torch.Tensor",
|
|
"shape": list(value.shape),
|
|
"dtype": str(value.dtype),
|
|
"device": str(value.device),
|
|
}
|
|
if value.numel() and value.numel() <= 32:
|
|
out["values"] = value.detach().cpu().tolist()
|
|
return out
|
|
if isinstance(value, np.ndarray):
|
|
out = {"type": "numpy.ndarray", "shape": list(value.shape), "dtype": str(value.dtype)}
|
|
if value.size and value.size <= 32:
|
|
out["values"] = value.tolist()
|
|
return out
|
|
if isinstance(value, Image.Image):
|
|
return {"type": "PIL.Image", "mode": value.mode, "size": list(value.size)}
|
|
if isinstance(value, dict):
|
|
return {str(key): _json_safe_value(item, depth + 1) for key, item in value.items()}
|
|
if isinstance(value, (list, tuple, set)):
|
|
items = list(value)
|
|
out = [_json_safe_value(item, depth + 1) for item in items[:100]]
|
|
if len(items) > 100:
|
|
out.append(f"... {len(items) - 100} more items ...")
|
|
return out
|
|
return {"type": type(value).__name__, "repr": repr(value)}
|
|
|
|
|
|
def _format_display_value(value: Any, mode: str, max_characters: int) -> dict[str, Any]:
|
|
requested_mode = str(mode or "auto").strip().lower()
|
|
if requested_mode not in {"auto", "text", "json", "repr"}:
|
|
requested_mode = "auto"
|
|
|
|
parsed_json = False
|
|
value_type = type(value).__name__
|
|
if isinstance(value, str):
|
|
text = value
|
|
if requested_mode in {"auto", "json"}:
|
|
stripped = value.strip()
|
|
if stripped:
|
|
try:
|
|
text = json.dumps(json.loads(stripped), indent=2, ensure_ascii=False)
|
|
parsed_json = True
|
|
value_type = "json"
|
|
except Exception:
|
|
if requested_mode == "json":
|
|
text = value
|
|
elif requested_mode == "repr":
|
|
text = repr(value)
|
|
elif requested_mode == "repr":
|
|
text = repr(value)
|
|
else:
|
|
safe_value = _json_safe_value(value)
|
|
text = json.dumps(safe_value, indent=2, ensure_ascii=False)
|
|
parsed_json = isinstance(safe_value, (dict, list))
|
|
value_type = str(safe_value.get("type") or value_type) if isinstance(safe_value, dict) else value_type
|
|
|
|
text, truncated = _truncate_text(text, max_characters)
|
|
return {"text": text, "type": value_type, "is_json": parsed_json, "truncated": truncated}
|
|
|
|
|
|
def _log_ok(node_name: str, **fields: Any) -> None:
|
|
parts: list[str] = []
|
|
for key, value in fields.items():
|
|
if value is None:
|
|
continue
|
|
text = _single_line(value, limit=120)
|
|
if text:
|
|
parts.append(f"{key}={text}")
|
|
suffix = f" {' '.join(parts)}" if parts else ""
|
|
print(f"[GenAsset][{node_name}] OK{suffix}")
|
|
|
|
|
|
def _log_error(node_name: str, error: Any) -> None:
|
|
print(f"[GenAsset][{node_name}] ERROR {_single_line(error)}")
|
|
|
|
|
|
def _genasset_config_paths() -> list[Path]:
|
|
out: list[Path] = []
|
|
env_path = os.getenv("GENASSET_CONFIG_PATH", "").strip()
|
|
if env_path:
|
|
out.append(Path(env_path).expanduser())
|
|
try:
|
|
import folder_paths # type: ignore
|
|
|
|
user_dir = getattr(folder_paths, "user_directory", "")
|
|
if isinstance(user_dir, str) and user_dir.strip():
|
|
out.append(Path(user_dir) / GENASSET_CONFIG_FILENAME)
|
|
except Exception:
|
|
pass
|
|
out.append(Path.home() / "Documents" / "ComfyUI" / "user" / GENASSET_CONFIG_FILENAME)
|
|
out.append(Path.cwd() / "user" / GENASSET_CONFIG_FILENAME)
|
|
|
|
deduped: list[Path] = []
|
|
seen: set[str] = set()
|
|
for item in out:
|
|
key = str(item)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
deduped.append(item)
|
|
return deduped
|
|
|
|
|
|
def _read_genasset_config() -> tuple[dict[str, Any], str]:
|
|
for path in _genasset_config_paths():
|
|
if not path.exists():
|
|
continue
|
|
try:
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
raise RuntimeError(f"Invalid {GENASSET_CONFIG_FILENAME} at {path}: {exc}") from exc
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError(f"Invalid {GENASSET_CONFIG_FILENAME} at {path}: expected a JSON object.")
|
|
return payload, str(path)
|
|
return {}, ""
|
|
|
|
|
|
def resolve_workspace_token(token: str) -> tuple[str, str, str]:
|
|
value = token.strip()
|
|
if value and value not in {WORKSPACE_TOKEN_PLACEHOLDER, TOKEN_FILE_HINT}:
|
|
return value, "widget", ""
|
|
|
|
env_value = os.getenv("GENASSET_WORKSPACE_TOKEN", "").strip()
|
|
if env_value:
|
|
return env_value, "env", "GENASSET_WORKSPACE_TOKEN"
|
|
|
|
config, config_path = _read_genasset_config()
|
|
config_value = pick_string(config.get("workspace_token"), config.get("token"))
|
|
if config_value:
|
|
return config_value, "genasset.json", config_path
|
|
|
|
raise RuntimeError(
|
|
"Paste your GenAsset token into token, or set GENASSET_WORKSPACE_TOKEN, "
|
|
"or create user/genasset.json with token or workspace_token."
|
|
)
|
|
|
|
|
|
def package_root() -> Path:
|
|
return Path(__file__).resolve().parent
|
|
|
|
|
|
def genasset_node_version() -> str:
|
|
pyproject = package_root() / "pyproject.toml"
|
|
try:
|
|
text = pyproject.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return "unknown"
|
|
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
|
|
return match.group(1) if match else "unknown"
|
|
|
|
|
|
def genasset_last_updated_iso() -> str:
|
|
candidates = [
|
|
package_root() / "pyproject.toml",
|
|
package_root() / "__init__.py",
|
|
package_root() / "nodes.py",
|
|
package_root() / "js" / "genasset_manager.js",
|
|
]
|
|
mtimes = []
|
|
for path in candidates:
|
|
try:
|
|
mtimes.append(path.stat().st_mtime)
|
|
except Exception:
|
|
pass
|
|
if not mtimes:
|
|
return ""
|
|
return datetime.fromtimestamp(max(mtimes), tz=timezone.utc).isoformat()
|
|
|
|
|
|
def version_tuple(value: str) -> tuple[int, ...]:
|
|
parts = re.findall(r"\d+", str(value or ""))
|
|
return tuple(int(part) for part in parts[:4]) or (0,)
|
|
|
|
|
|
def compare_versions(current: str, latest: str) -> int:
|
|
current_tuple = version_tuple(current)
|
|
latest_tuple = version_tuple(latest)
|
|
max_len = max(len(current_tuple), len(latest_tuple))
|
|
current_tuple = current_tuple + (0,) * (max_len - len(current_tuple))
|
|
latest_tuple = latest_tuple + (0,) * (max_len - len(latest_tuple))
|
|
return (latest_tuple > current_tuple) - (latest_tuple < current_tuple)
|
|
|
|
|
|
def genasset_token_configured() -> tuple[bool, str, str]:
|
|
if os.getenv("GENASSET_WORKSPACE_TOKEN", "").strip():
|
|
return True, "env", "GENASSET_WORKSPACE_TOKEN"
|
|
try:
|
|
config, config_path = _read_genasset_config()
|
|
if pick_string(config.get("workspace_token"), config.get("token")):
|
|
return True, "genasset.json", config_path
|
|
except Exception:
|
|
return False, "", ""
|
|
return False, "", ""
|
|
|
|
|
|
def genasset_manager_status(check: bool = False) -> dict[str, Any]:
|
|
base_url = _genasset_catalog_base_url()
|
|
token_configured, token_source, token_source_ref = genasset_token_configured()
|
|
out: dict[str, Any] = {
|
|
"ok": True,
|
|
"name": "GenAsset Node",
|
|
"version": genasset_node_version(),
|
|
"last_updated": genasset_last_updated_iso(),
|
|
"base_url": base_url,
|
|
"token_configured": token_configured,
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
"connection_checked": False,
|
|
"connected": None,
|
|
"api_reachable": None,
|
|
"workspace_synced": None,
|
|
"workspace": None,
|
|
"organization": None,
|
|
"workspaces": [],
|
|
"counts": {},
|
|
}
|
|
if not check:
|
|
return out
|
|
out["connection_checked"] = True
|
|
try:
|
|
workspace_token, resolved_source, resolved_ref = resolve_workspace_token(TOKEN_FILE_HINT)
|
|
out["token_source"] = resolved_source
|
|
out["token_source_ref"] = resolved_ref
|
|
url = urllib.parse.urljoin(base_url + "/", "api/v1/workspace?lite=1&include_workspace_list=1")
|
|
data = request_json("GET", url, workspace_token)
|
|
workspace = data.get("workspace") if isinstance(data.get("workspace"), dict) else {}
|
|
out.update(
|
|
{
|
|
"connected": True,
|
|
"api_reachable": True,
|
|
"workspace_synced": bool(workspace),
|
|
"workspace": {
|
|
"id": str(workspace.get("id") or ""),
|
|
"name": str(workspace.get("name") or ""),
|
|
"slug": str(workspace.get("slug") or ""),
|
|
},
|
|
"organization": workspace.get("organization") if isinstance(workspace.get("organization"), dict) else None,
|
|
"workspaces": workspace.get("workspaces") if isinstance(workspace.get("workspaces"), list) else [],
|
|
"counts": data.get("counts") if isinstance(data.get("counts"), dict) else {},
|
|
}
|
|
)
|
|
except Exception as exc:
|
|
out.update({"ok": False, "connected": False, "api_reachable": False, "workspace_synced": False, "error": str(exc)})
|
|
return out
|
|
|
|
|
|
def fetch_latest_genasset_node_version() -> dict[str, Any]:
|
|
current = genasset_node_version()
|
|
latest = ""
|
|
try:
|
|
text = urllib.request.urlopen(
|
|
urllib.request.Request(RAW_GITHUB_PYPROJECT_URL, headers={"User-Agent": "ComfyUI-GenAsset/manager"}),
|
|
timeout=20,
|
|
).read(256 * 1024).decode("utf-8", errors="replace")
|
|
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
|
|
latest = match.group(1) if match else ""
|
|
if not latest:
|
|
raise RuntimeError("Could not read latest version.")
|
|
comparison = compare_versions(current, latest)
|
|
return {
|
|
"ok": True,
|
|
"current_version": current,
|
|
"latest_version": latest,
|
|
"update_available": comparison > 0,
|
|
"message": f"Update available: v{latest}" if comparison > 0 else "GenAsset is up to date.",
|
|
}
|
|
except Exception as exc:
|
|
return {
|
|
"ok": False,
|
|
"current_version": current,
|
|
"latest_version": latest,
|
|
"update_available": False,
|
|
"error": str(exc),
|
|
"message": "Could not check for updates.",
|
|
}
|
|
|
|
|
|
def _candidate_comfyui_roots() -> list[Path]:
|
|
roots: list[Path] = []
|
|
for value in (os.getenv("COMFYUI_PATH"), os.getenv("COMFYUI_ROOT")):
|
|
if value:
|
|
roots.append(Path(value).expanduser())
|
|
|
|
root = package_root()
|
|
for parent in [root, *root.parents]:
|
|
if parent.name == "custom_nodes" and parent.parent:
|
|
roots.append(parent.parent)
|
|
if (parent / "main.py").exists() and (parent / "custom_nodes").exists():
|
|
roots.append(parent)
|
|
|
|
try:
|
|
cwd = Path.cwd()
|
|
roots.append(cwd)
|
|
if cwd.name == "custom_nodes" and cwd.parent:
|
|
roots.append(cwd.parent)
|
|
except Exception:
|
|
pass
|
|
|
|
unique: list[Path] = []
|
|
seen: set[str] = set()
|
|
for root_path in roots:
|
|
try:
|
|
resolved = root_path.resolve()
|
|
except Exception:
|
|
resolved = root_path
|
|
key = str(resolved)
|
|
if key not in seen:
|
|
seen.add(key)
|
|
unique.append(resolved)
|
|
return unique
|
|
|
|
|
|
def _find_manager_cli() -> tuple[Path, Path]:
|
|
checked: list[str] = []
|
|
manager_dir_names = ("ComfyUI-Manager", "ComfyUI-Manager-main", "comfyui-manager")
|
|
for comfyui_root in _candidate_comfyui_roots():
|
|
for manager_dir_name in manager_dir_names:
|
|
candidate = comfyui_root / "custom_nodes" / manager_dir_name / "cm-cli.py"
|
|
checked.append(str(candidate))
|
|
if candidate.exists():
|
|
return comfyui_root, candidate
|
|
checked_text = "; ".join(checked[:8])
|
|
raise RuntimeError(f"ComfyUI-Manager cm-cli.py was not found. Checked: {checked_text}")
|
|
|
|
|
|
def _run_manager_cli(args: list[str], comfyui_root: Path, manager_cli: Path) -> subprocess.CompletedProcess[str]:
|
|
env = os.environ.copy()
|
|
env["COMFYUI_PATH"] = str(comfyui_root)
|
|
env.setdefault("PYTHONUNBUFFERED", "1")
|
|
return subprocess.run(
|
|
[sys.executable, str(manager_cli), *args],
|
|
cwd=str(comfyui_root),
|
|
env=env,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
timeout=300,
|
|
check=False,
|
|
)
|
|
|
|
|
|
def _tail_output(text: str, limit: int = 4000) -> str:
|
|
clean = str(text or "").strip()
|
|
if len(clean) <= limit:
|
|
return clean
|
|
return clean[-limit:]
|
|
|
|
|
|
def _validate_update_version(version: str) -> str:
|
|
clean = str(version or "").strip()
|
|
if not re.fullmatch(r"\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?", clean):
|
|
raise RuntimeError("Latest GenAsset version is not valid for an automatic update.")
|
|
return clean
|
|
|
|
|
|
def perform_genasset_node_update() -> dict[str, Any]:
|
|
if not _GENASSET_UPDATE_LOCK.acquire(blocking=False):
|
|
return {"ok": False, "updated": False, "error": "A GenAsset update is already running."}
|
|
try:
|
|
update = fetch_latest_genasset_node_version()
|
|
current = str(update.get("current_version") or genasset_node_version())
|
|
latest = str(update.get("latest_version") or "")
|
|
if update.get("ok") and latest and compare_versions(current, latest) <= 0:
|
|
return {
|
|
"ok": True,
|
|
"updated": False,
|
|
"current_version": current,
|
|
"latest_version": latest,
|
|
"restart_required": False,
|
|
"message": "GenAsset is already up to date.",
|
|
}
|
|
if not latest:
|
|
raise RuntimeError(str(update.get("error") or "Could not determine the latest GenAsset version."))
|
|
|
|
target_version = _validate_update_version(latest)
|
|
comfyui_root, manager_cli = _find_manager_cli()
|
|
attempts = [
|
|
["install", f"{GENASSET_MANAGER_PACKAGE_ID}@{target_version}", "--mode", "remote", "--channel", "default"],
|
|
["update", GENASSET_MANAGER_PACKAGE_ID, "--mode", "remote", "--channel", "default"],
|
|
]
|
|
outputs: list[str] = []
|
|
for args in attempts:
|
|
result = _run_manager_cli(args, comfyui_root, manager_cli)
|
|
output = _tail_output(result.stdout)
|
|
outputs.append(f"$ {' '.join(args)}\n{output}")
|
|
if result.returncode == 0:
|
|
return {
|
|
"ok": True,
|
|
"updated": True,
|
|
"current_version": current,
|
|
"latest_version": target_version,
|
|
"restart_required": True,
|
|
"manager_cli": str(manager_cli),
|
|
"comfyui_root": str(comfyui_root),
|
|
"message": f"Updated GenAsset to v{target_version}. Restart ComfyUI to load it.",
|
|
"output": output,
|
|
}
|
|
|
|
raise RuntimeError("ComfyUI-Manager could not update GenAsset.\n" + "\n\n".join(outputs))
|
|
except Exception as exc:
|
|
return {
|
|
"ok": False,
|
|
"updated": False,
|
|
"current_version": genasset_node_version(),
|
|
"restart_required": False,
|
|
"error": str(exc),
|
|
"message": "GenAsset update failed.",
|
|
}
|
|
finally:
|
|
_GENASSET_UPDATE_LOCK.release()
|
|
|
|
|
|
def workflow_name_from_version(version: dict[str, Any], fallback: str) -> str:
|
|
metadata = version.get("metadata") if isinstance(version.get("metadata"), dict) else {}
|
|
workflow_meta = metadata.get("workflow") if isinstance(metadata.get("workflow"), dict) else {}
|
|
workflow_json = version.get("workflow_json") if isinstance(version.get("workflow_json"), dict) else {}
|
|
return pick_string(workflow_meta.get("name"), workflow_json.get("name"), fallback)
|
|
|
|
|
|
def _display_name_from_email(value: str) -> str:
|
|
clean = str(value or "").strip()
|
|
if "@" not in clean:
|
|
return clean
|
|
local = clean.split("@", 1)[0]
|
|
parts = [part for part in re.split(r"[._\-]+", local) if part]
|
|
return " ".join(part[:1].upper() + part[1:] for part in parts) or clean
|
|
|
|
|
|
def manager_actor_name(*values: Any) -> str:
|
|
for value in values:
|
|
if isinstance(value, dict):
|
|
name = pick_string(
|
|
value.get("display_name"),
|
|
value.get("full_name"),
|
|
value.get("name"),
|
|
value.get("username"),
|
|
)
|
|
if name:
|
|
return name
|
|
email = pick_string(value.get("email"))
|
|
if email:
|
|
return _display_name_from_email(email)
|
|
else:
|
|
text = pick_string(value)
|
|
if text:
|
|
return _display_name_from_email(text)
|
|
return ""
|
|
|
|
|
|
def compact_manager_asset(asset: dict[str, Any]) -> dict[str, Any]:
|
|
current = asset.get("current_version") if isinstance(asset.get("current_version"), dict) else {}
|
|
metadata = asset.get("metadata") if isinstance(asset.get("metadata"), dict) else {}
|
|
current_metadata = current.get("metadata") if isinstance(current.get("metadata"), dict) else {}
|
|
workflow_json = current.get("workflow_json") if isinstance(current.get("workflow_json"), dict) else {}
|
|
node_count = 0
|
|
importable = False
|
|
try:
|
|
workflow = _normalize_import_workflow_payload(workflow_json)
|
|
node_count = len(workflow.get("nodes") or [])
|
|
importable = True
|
|
except Exception:
|
|
pass
|
|
return {
|
|
"id": str(asset.get("id") or ""),
|
|
"name": str(asset.get("name") or "Untitled asset"),
|
|
"updated_at": str(asset.get("updated_at") or ""),
|
|
"current_version_id": str((current or {}).get("id") or asset.get("current_version_id") or ""),
|
|
"version_number": (current or {}).get("version_number"),
|
|
"workflow_name": workflow_name_from_version(current, str(asset.get("name") or "Workflow")),
|
|
"user_name": manager_actor_name(
|
|
asset.get("updated_by"),
|
|
asset.get("created_by"),
|
|
asset.get("owner"),
|
|
asset.get("user"),
|
|
metadata.get("updated_by"),
|
|
metadata.get("created_by"),
|
|
metadata.get("owner"),
|
|
metadata.get("user"),
|
|
current.get("created_by"),
|
|
current.get("user"),
|
|
current_metadata.get("created_by"),
|
|
current_metadata.get("user"),
|
|
),
|
|
"workflow_importable": importable,
|
|
"node_count": node_count,
|
|
}
|
|
|
|
|
|
def genasset_manager_recent(page_size: int = 10, search: str = "") -> dict[str, Any]:
|
|
base_url = _genasset_catalog_base_url()
|
|
workspace_token = require_workspace_token(TOKEN_FILE_HINT)
|
|
safe_search = str(search or "").strip()[:120]
|
|
safe_size = max(1, min(50 if safe_search else 12, int(page_size or 10)))
|
|
params: dict[str, str] = {"page_size": str(safe_size)}
|
|
if safe_search:
|
|
params["search"] = safe_search
|
|
path = f"api/v1/assets?{urllib.parse.urlencode(params)}"
|
|
data = request_json("GET", urllib.parse.urljoin(base_url + "/", path), workspace_token)
|
|
raw_assets = data.get("assets") if isinstance(data.get("assets"), list) else []
|
|
assets = [compact_manager_asset(asset) for asset in raw_assets if isinstance(asset, dict)]
|
|
workflows = [asset for asset in assets if asset.get("workflow_importable")]
|
|
return {"assets": assets, "workflows": workflows, "pagination": data.get("pagination") or {}}
|
|
|
|
|
|
def genasset_manager_import_workspace_workflow(asset_id: str) -> dict[str, Any]:
|
|
clean_asset_id = str(asset_id or "").strip()
|
|
if not looks_like_uuid(clean_asset_id):
|
|
raise RuntimeError("Invalid GenAsset asset id.")
|
|
base_url = _genasset_catalog_base_url()
|
|
workspace_token = require_workspace_token(TOKEN_FILE_HINT)
|
|
path = f"api/v1/assets/{urllib.parse.quote(clean_asset_id)}?signed_artifacts=0"
|
|
data = request_json("GET", urllib.parse.urljoin(base_url + "/", path), workspace_token)
|
|
asset = data.get("asset") if isinstance(data.get("asset"), dict) else {}
|
|
versions = data.get("versions") if isinstance(data.get("versions"), list) else []
|
|
if not versions:
|
|
raise RuntimeError("This asset has no versions.")
|
|
current_id = str(asset.get("current_version_id") or "")
|
|
version = next((item for item in versions if isinstance(item, dict) and str(item.get("id") or "") == current_id), None)
|
|
if version is None:
|
|
version = versions[0] if isinstance(versions[0], dict) else {}
|
|
workflow = _normalize_import_workflow_payload(version.get("workflow_json") if isinstance(version, dict) else {})
|
|
return {
|
|
"ok": True,
|
|
"id": clean_asset_id,
|
|
"title": workflow_name_from_version(version, str(asset.get("name") or "GenAsset workflow")),
|
|
"asset": {"id": str(asset.get("id") or clean_asset_id), "name": str(asset.get("name") or "")},
|
|
"version": {"id": str(version.get("id") or ""), "version_number": version.get("version_number")},
|
|
"workflow": workflow,
|
|
"warnings": [],
|
|
}
|
|
|
|
|
|
try:
|
|
from aiohttp import web # type: ignore
|
|
from server import PromptServer # type: ignore
|
|
|
|
def _genasset_manager_origin_block(request): # type: ignore[no-untyped-def]
|
|
origin = str(request.headers.get("Origin") or request.headers.get("Referer") or "").strip()
|
|
if not origin:
|
|
return None
|
|
try:
|
|
origin_host = urllib.parse.urlparse(origin).netloc.lower()
|
|
except Exception:
|
|
origin_host = ""
|
|
request_host = str(request.headers.get("Host") or getattr(request, "host", "") or "").lower()
|
|
origin_name = origin_host.split(":", 1)[0].strip("[]")
|
|
if origin_name in {"localhost", "127.0.0.1", "::1"}:
|
|
return None
|
|
if origin_host and request_host and origin_host != request_host:
|
|
return web.json_response(
|
|
{"ok": False, "error": "Cross-origin GenAsset manager requests are not allowed."},
|
|
status=403,
|
|
)
|
|
return None
|
|
|
|
@PromptServer.instance.routes.get("/genasset/manager/status")
|
|
async def genasset_manager_status_route(request): # type: ignore[no-untyped-def]
|
|
blocked = _genasset_manager_origin_block(request)
|
|
if blocked is not None:
|
|
return blocked
|
|
check = request.query.get("check", "0") in {"1", "true", "yes"}
|
|
try:
|
|
return web.json_response(genasset_manager_status(check=check))
|
|
except Exception as exc:
|
|
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
|
|
|
@PromptServer.instance.routes.get("/genasset/manager/update-check")
|
|
async def genasset_manager_update_check_route(request): # type: ignore[no-untyped-def]
|
|
blocked = _genasset_manager_origin_block(request)
|
|
if blocked is not None:
|
|
return blocked
|
|
return web.json_response(fetch_latest_genasset_node_version())
|
|
|
|
@PromptServer.instance.routes.post("/genasset/manager/update")
|
|
async def genasset_manager_update_route(request): # type: ignore[no-untyped-def]
|
|
blocked = _genasset_manager_origin_block(request)
|
|
if blocked is not None:
|
|
return blocked
|
|
return web.json_response(await asyncio.to_thread(perform_genasset_node_update))
|
|
|
|
@PromptServer.instance.routes.get("/genasset/manager/recent")
|
|
async def genasset_manager_recent_route(request): # type: ignore[no-untyped-def]
|
|
blocked = _genasset_manager_origin_block(request)
|
|
if blocked is not None:
|
|
return blocked
|
|
try:
|
|
page_size = pick_int(request.query.get("page_size"), 10)
|
|
search = str(request.query.get("search") or "")
|
|
return web.json_response(genasset_manager_recent(page_size=page_size, search=search))
|
|
except Exception as exc:
|
|
return web.json_response({"error": str(exc), "assets": [], "workflows": []}, status=502)
|
|
|
|
@PromptServer.instance.routes.get("/genasset/manager/workspace-workflows/{asset_id}")
|
|
async def genasset_manager_workspace_workflow_route(request): # type: ignore[no-untyped-def]
|
|
blocked = _genasset_manager_origin_block(request)
|
|
if blocked is not None:
|
|
return blocked
|
|
try:
|
|
asset_id = request.match_info.get("asset_id", "")
|
|
return web.json_response(genasset_manager_import_workspace_workflow(asset_id))
|
|
except Exception as exc:
|
|
return web.json_response({"error": str(exc)}, status=404)
|
|
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def key_loaded_note(token_source: str, token_source_ref: str) -> str:
|
|
if token_source != "genasset.json":
|
|
return ""
|
|
display_ref = "ComfyUI/user/genasset.json"
|
|
if token_source_ref:
|
|
normalized = token_source_ref.replace("\\", "/")
|
|
marker = "ComfyUI/user/genasset.json"
|
|
if marker in normalized:
|
|
display_ref = normalized[normalized.index(marker):]
|
|
else:
|
|
display_ref = normalized
|
|
return f" Token loaded from {display_ref}."
|
|
|
|
|
|
def require_base_url(base_url: str) -> str:
|
|
value = base_url.strip().rstrip("/")
|
|
if not value:
|
|
raise RuntimeError("Paste your GenAsset URL into base_url.")
|
|
return value
|
|
|
|
|
|
def require_workspace_token(workspace_token: str) -> str:
|
|
value, _, _ = resolve_workspace_token(workspace_token)
|
|
return value
|
|
|
|
|
|
def parse_tags_csv(value: str) -> list[str]:
|
|
raw = [part.strip() for part in str(value or "").split(",")]
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for part in raw:
|
|
if not part:
|
|
continue
|
|
key = part.lower()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(part)
|
|
return out
|
|
|
|
|
|
def merge_unique_tags(*tag_lists: list[str]) -> list[str]:
|
|
out: list[str] = []
|
|
seen: set[str] = set()
|
|
for tag_list in tag_lists:
|
|
for tag in tag_list:
|
|
text = str(tag or "").strip()
|
|
if not text:
|
|
continue
|
|
key = text.lower()
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(text)
|
|
return out[:32]
|
|
|
|
|
|
def deep_merge_dict(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
out = dict(base)
|
|
for key, value in patch.items():
|
|
if isinstance(out.get(key), dict) and isinstance(value, dict):
|
|
out[key] = deep_merge_dict(out[key], value)
|
|
else:
|
|
out[key] = value
|
|
return out
|
|
|
|
|
|
def compact_workflow_context(
|
|
api_prompt: dict[str, Any],
|
|
extra_pnginfo: Any,
|
|
unique_id: Any,
|
|
image: torch.Tensor | None = None,
|
|
asset_name_hint: str = "",
|
|
asset_id_hint: str = "",
|
|
) -> dict[str, Any]:
|
|
uid = str(unique_id[0] if isinstance(unique_id, (list, tuple)) and unique_id else unique_id or "")
|
|
assistant_node = api_prompt.get(uid) if uid else None
|
|
image_source_id = input_link(assistant_node, "image") if isinstance(assistant_node, dict) else None
|
|
if image_source_id:
|
|
upstream_ids = walk_upstream(api_prompt, image_source_id)
|
|
else:
|
|
upstream_ids = list(api_prompt.keys())
|
|
sampler_id, sampler = find_first_node_by_class(
|
|
api_prompt,
|
|
upstream_ids,
|
|
{"KSampler", "KSamplerAdvanced", "SamplerCustom", "SamplerCustomAdvanced"},
|
|
)
|
|
positive_prompt, negative_prompt = collect_prompt_texts(api_prompt, sampler, upstream_ids)
|
|
sampler_metadata = collect_sampler_metadata(sampler)
|
|
model = collect_checkpoint_name(api_prompt, upstream_ids)
|
|
latent = collect_latent_metadata(api_prompt, upstream_ids)
|
|
image_info: dict[str, Any] = {}
|
|
if image is not None:
|
|
try:
|
|
image_batch = _ensure_image_batch(image)
|
|
image_info = {
|
|
"width": int(image_batch.shape[2]),
|
|
"height": int(image_batch.shape[1]),
|
|
"batch_size": int(image_batch.shape[0]),
|
|
}
|
|
except Exception:
|
|
image_info = {}
|
|
save_nodes = []
|
|
genasset_nodes = []
|
|
for node_id, node in api_prompt.items():
|
|
if not isinstance(node, dict):
|
|
continue
|
|
klass = node_class(node)
|
|
if klass.startswith("GenAsset"):
|
|
inputs = node.get("inputs") if isinstance(node.get("inputs"), dict) else {}
|
|
compact_inputs = {}
|
|
for key, value in inputs.items():
|
|
if str(key).lower() in {"token", "workspace_token", "authorization", "api_key", "secret"}:
|
|
compact_inputs[key] = "[redacted]"
|
|
elif isinstance(value, (str, int, float, bool)) or value is None:
|
|
compact_inputs[key] = value
|
|
elif isinstance(value, (list, tuple)):
|
|
compact_inputs[key] = list(value[:2])
|
|
record = {"id": str(node_id), "class_type": klass, "inputs": compact_inputs}
|
|
genasset_nodes.append(record)
|
|
if klass == "GenAssetSaveGeneration":
|
|
save_nodes.append(record)
|
|
workflow = workflow_from_extra(extra_pnginfo)
|
|
return {
|
|
"assistant_node_id": uid,
|
|
"asset_name_hint": asset_name_hint.strip(),
|
|
"asset_id_hint": asset_id_hint.strip(),
|
|
"prompt": positive_prompt,
|
|
"negative_prompt": negative_prompt,
|
|
"model": model,
|
|
"seed": sampler_metadata.get("seed", 0),
|
|
"sampler": sampler_metadata,
|
|
"latent": latent,
|
|
"image": image_info,
|
|
"capture": {
|
|
"image_source_node_id": image_source_id,
|
|
"sampler_node_id": sampler_id,
|
|
"upstream_node_ids": upstream_ids,
|
|
},
|
|
"genasset_nodes": genasset_nodes,
|
|
"save_nodes": save_nodes,
|
|
"workflow_json": redact_secret_fields(
|
|
{
|
|
"api_prompt": api_prompt,
|
|
"workflow": workflow,
|
|
"captured_from_node_id": uid,
|
|
"captured_upstream_node_ids": upstream_ids,
|
|
}
|
|
),
|
|
"auto_tags": derive_tags(positive_prompt, model),
|
|
}
|
|
|
|
|
|
def compact_asset_row(asset: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": str(asset.get("id") or ""),
|
|
"name": str(asset.get("name") or ""),
|
|
"tags": asset.get("tags") if isinstance(asset.get("tags"), list) else [],
|
|
"current_version_id": str(asset.get("current_version_id") or ""),
|
|
"updated_at": str(asset.get("updated_at") or ""),
|
|
"created_at": str(asset.get("created_at") or ""),
|
|
}
|
|
|
|
|
|
def compact_version_row(version: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"id": str(version.get("id") or ""),
|
|
"asset_id": str(version.get("asset_id") or ""),
|
|
"version_number": version.get("version_number"),
|
|
"model": str(version.get("model") or ""),
|
|
"seed": version.get("seed"),
|
|
"tags": version.get("tags") if isinstance(version.get("tags"), list) else [],
|
|
"created_at": str(version.get("created_at") or ""),
|
|
}
|
|
|
|
|
|
class GenAssetTestConnection:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("workspace_name", "status_json", "summary", "state", "normalized_status_json")
|
|
FUNCTION = "test"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def test(self, base_url: str, token: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
url = urllib.parse.urljoin(clean_base_url + "/", "api/v1/workspace")
|
|
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {clean_workspace_token}"})
|
|
data = read_json(request)
|
|
workspace = data.get("workspace") or {}
|
|
status = {
|
|
"ok": True,
|
|
"base_url": clean_base_url,
|
|
"workspace": {
|
|
"id": workspace.get("id", ""),
|
|
"name": workspace.get("name", ""),
|
|
"slug": workspace.get("slug", ""),
|
|
},
|
|
"counts": data.get("counts") or {},
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
"message": "Connected to GenAsset.",
|
|
}
|
|
workspace_name = str(workspace.get("name") or "")
|
|
summary = (
|
|
f"SUCCESS: Connected to workspace '{workspace_name}'."
|
|
if workspace_name
|
|
else "SUCCESS: Connected to GenAsset."
|
|
)
|
|
summary += key_loaded_note(token_source, token_source_ref)
|
|
normalized = json.dumps(status, indent=2)
|
|
return {"ui": {"text": [summary]}, "result": (workspace_name, normalized, summary, "success", normalized)}
|
|
except Exception as exc:
|
|
_log_error("TestGenAssetConnection", exc)
|
|
status = {
|
|
"ok": False,
|
|
"base_url": base_url.strip().rstrip("/"),
|
|
"error": str(exc),
|
|
"next_step": "Paste your GenAsset URL and token, then run this node again.",
|
|
}
|
|
summary = f"ERROR: {status['error']}"
|
|
normalized = json.dumps(status, indent=2)
|
|
return {"ui": {"text": [summary]}, "result": ("", normalized, summary, "error", normalized)}
|
|
|
|
|
|
class GenAssetDisplayAny:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"title": ("STRING", {"default": "GenAsset display", "tooltip": "Optional heading shown above the displayed value."}),
|
|
"fallback_text": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": True,
|
|
"tooltip": "Used when nothing is connected to value. Paste text or JSON here.",
|
|
},
|
|
),
|
|
"format": (["auto", "text", "json", "repr"], {"default": "auto"}),
|
|
"max_characters": ("INT", {"default": 12000, "min": 200, "max": 200000}),
|
|
},
|
|
"optional": {
|
|
"value": (GENASSET_ANY, {"tooltip": "Connect any GenAsset or ComfyUI output here."}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = (GENASSET_ANY, "STRING")
|
|
RETURN_NAMES = ("value", "text")
|
|
FUNCTION = "display"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def display(self, title: str, fallback_text: str, format: str, max_characters: int, value: Any = None):
|
|
display_value = fallback_text if value is None else value
|
|
formatted = _format_display_value(display_value, format, max_characters)
|
|
clean_title = str(title or "GenAsset display").strip() or "GenAsset display"
|
|
text = formatted["text"]
|
|
ui_text = f"{clean_title}\n\n{text}" if clean_title else text
|
|
if formatted.get("truncated"):
|
|
ui_text += "\n\n(Display truncated. Increase max_characters to show more.)"
|
|
return {
|
|
"ui": {
|
|
"text": [ui_text],
|
|
"genasset_display": [
|
|
{
|
|
"title": clean_title,
|
|
"text": text,
|
|
"type": formatted.get("type") or "value",
|
|
"is_json": bool(formatted.get("is_json")),
|
|
"truncated": bool(formatted.get("truncated")),
|
|
}
|
|
],
|
|
},
|
|
"result": (display_value, text),
|
|
}
|
|
|
|
|
|
class GenAssetWorkflowAssistant:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
},
|
|
"optional": {
|
|
"image": ("IMAGE",),
|
|
"asset_name_hint": ("STRING", {"default": "", "tooltip": "Optional starting point for the suggested asset name."}),
|
|
"asset_id_hint": ("STRING", {"default": "", "tooltip": "Optional existing asset id to prefer."}),
|
|
},
|
|
"hidden": {
|
|
"prompt": "PROMPT",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
"unique_id": "UNIQUE_ID",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = (
|
|
"asset_name",
|
|
"asset_id",
|
|
"tags_csv",
|
|
"intent",
|
|
"notes_md",
|
|
"version_label",
|
|
"metadata_json",
|
|
"warnings_json",
|
|
"status_json",
|
|
"summary",
|
|
)
|
|
FUNCTION = "assist"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def assist(
|
|
self,
|
|
base_url: str,
|
|
token: str,
|
|
image: torch.Tensor | None = None,
|
|
asset_name_hint: str = "",
|
|
asset_id_hint: str = "",
|
|
prompt: Any = None,
|
|
extra_pnginfo: Any = None,
|
|
unique_id: Any = None,
|
|
):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
api_prompt = api_prompt_from_hidden(prompt)
|
|
context = compact_workflow_context(
|
|
api_prompt=api_prompt,
|
|
extra_pnginfo=extra_pnginfo,
|
|
unique_id=unique_id,
|
|
image=image,
|
|
asset_name_hint=asset_name_hint,
|
|
asset_id_hint=asset_id_hint,
|
|
)
|
|
context["client"] = {
|
|
"source": "comfyui-genasset",
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
url = urllib.parse.urljoin(clean_base_url + "/", "api/v1/comfy/workflow-assist")
|
|
data = request_json("POST", url, clean_workspace_token, context)
|
|
suggestions = data.get("suggestions") if isinstance(data.get("suggestions"), dict) else {}
|
|
warnings = data.get("warnings") if isinstance(data.get("warnings"), list) else []
|
|
asset_name = str(suggestions.get("asset_name") or data.get("asset_name") or asset_name_hint or "Untitled asset").strip()
|
|
asset_id = str(suggestions.get("asset_id") or asset_id_hint or "").strip()
|
|
tags = suggestions.get("tags")
|
|
tags_csv = ", ".join(str(tag).strip() for tag in tags if str(tag).strip()) if isinstance(tags, list) else str(suggestions.get("tags_csv") or "")
|
|
intent = str(suggestions.get("intent") or "").strip()
|
|
notes_md = str(suggestions.get("notes_md") or "").strip()
|
|
version_label = str(suggestions.get("version_label") or "").strip()
|
|
metadata = suggestions.get("metadata") if isinstance(suggestions.get("metadata"), dict) else {}
|
|
metadata_json = json.dumps(metadata, indent=2)
|
|
warnings_json = json.dumps(warnings, indent=2)
|
|
status = {
|
|
"ok": bool(data.get("ok", True)),
|
|
"source": data.get("source") or "genasset",
|
|
"workspace": data.get("workspace") or {},
|
|
"warning_count": len(warnings),
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
summary = str(data.get("summary") or f"Suggested asset name: {asset_name}").strip()
|
|
if token_source == "genasset.json":
|
|
summary += key_loaded_note(token_source, token_source_ref)
|
|
status_json = json.dumps(status, indent=2)
|
|
return {
|
|
"ui": {"text": [summary, warnings_json]},
|
|
"result": (asset_name, asset_id, tags_csv, intent, notes_md, version_label, metadata_json, warnings_json, status_json, summary),
|
|
}
|
|
except Exception as exc:
|
|
_log_error("WorkflowAssistant", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
status_json = json.dumps(status, indent=2)
|
|
summary = f"ERROR: {str(exc)}"
|
|
return {
|
|
"ui": {"text": [summary]},
|
|
"result": ("", "", "", "", "", "", "{}", "[]", status_json, summary),
|
|
}
|
|
|
|
|
|
class GenAssetSaveGeneration:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_name": ("STRING", {"default": "Untitled asset"}),
|
|
},
|
|
"optional": {
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Optional. Save to this existing GenAsset asset id."}),
|
|
"tags_csv": ("STRING", {"default": "", "tooltip": "Optional. Extra tags to merge with auto-captured tags."}),
|
|
"intent": ("STRING", {"default": "", "tooltip": "Optional. Workflow intent, for example txt2img, img2img, inpaint, or upscale."}),
|
|
"notes_md": ("STRING", {"default": "", "multiline": True, "tooltip": "Optional. Asset-level notes to save with this generation."}),
|
|
"version_label": ("STRING", {"default": "", "tooltip": "Optional. Short label stored in version metadata."}),
|
|
"metadata_json": ("STRING", {"default": "{}", "multiline": True, "tooltip": "Optional. Extra JSON metadata merged into captured metadata."}),
|
|
"source": ("STRING", {"default": "comfyui", "tooltip": "Optional source label. Defaults to comfyui."}),
|
|
},
|
|
"hidden": {
|
|
"prompt": "PROMPT",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
"unique_id": "UNIQUE_ID",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("image", "asset_id", "version_id", "status_json")
|
|
FUNCTION = "save"
|
|
CATEGORY = CATEGORY
|
|
|
|
def save(
|
|
self,
|
|
image: torch.Tensor,
|
|
base_url: str,
|
|
token: str,
|
|
asset_name: str,
|
|
asset_id: str = "",
|
|
tags_csv: str = "",
|
|
intent: str = "",
|
|
notes_md: str = "",
|
|
version_label: str = "",
|
|
metadata_json: str = "{}",
|
|
source: str = "comfyui",
|
|
prompt: Any = None,
|
|
extra_pnginfo: Any = None,
|
|
unique_id: Any = None,
|
|
):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, _ = resolve_workspace_token(token)
|
|
api_prompt = api_prompt_from_hidden(prompt)
|
|
quality = image_quality_metrics(image)
|
|
if quality["blank_or_black_rejected"]:
|
|
status = {
|
|
"saved": False,
|
|
"error": "Image looks blank or black, so GenAsset did not create a version.",
|
|
"image_quality": quality,
|
|
"next_step": "Rerun the sampler with a different seed, more steps, lower denoise, or a safer model/prompt before saving.",
|
|
}
|
|
_log_error("SaveToGenAsset", status["error"])
|
|
return (image, "", "", json.dumps(status, indent=2))
|
|
capture = auto_capture_generation(
|
|
image=image,
|
|
api_prompt=api_prompt,
|
|
extra_pnginfo=extra_pnginfo,
|
|
unique_id=unique_id,
|
|
asset_name=asset_name,
|
|
)
|
|
capture["metadata"]["repro_lock"] = build_repro_lock(api_prompt, workflow_from_extra(extra_pnginfo))
|
|
capture["metadata"]["image_quality"] = quality
|
|
capture["metadata"]["validation"] = {
|
|
"black_image_guard": "passed",
|
|
"black_image_guard_rule": quality["rule"],
|
|
}
|
|
extra_metadata = parse_json(metadata_json, {})
|
|
if not isinstance(extra_metadata, dict):
|
|
extra_metadata = {}
|
|
label_text = version_label.strip()
|
|
notes_text = notes_md.strip()
|
|
assistant_metadata = {}
|
|
if label_text:
|
|
assistant_metadata["version_label"] = label_text
|
|
if notes_text:
|
|
assistant_metadata["notes_md"] = notes_text
|
|
if assistant_metadata:
|
|
extra_metadata = deep_merge_dict(extra_metadata, {"genasset_assistant": assistant_metadata})
|
|
if extra_metadata:
|
|
capture["metadata"] = deep_merge_dict(capture["metadata"], extra_metadata)
|
|
tags = merge_unique_tags(capture.get("tags", []), parse_tags_csv(tags_csv))
|
|
input_image_files = collect_input_image_files(api_prompt, capture["metadata"]["capture"]["upstream_node_ids"])
|
|
fields = {
|
|
"asset_name": asset_name,
|
|
"asset_id": asset_id.strip(),
|
|
"prompt": capture["prompt"],
|
|
"negative_prompt": capture["negative_prompt"],
|
|
"workflow_json": json.dumps(capture["workflow_json"]),
|
|
"model": capture["model"],
|
|
"seed": str(capture["seed"]),
|
|
"tags": ", ".join(tags),
|
|
"intent": intent.strip(),
|
|
"metadata": json.dumps(capture["metadata"]),
|
|
"source": source.strip() or "comfyui",
|
|
"notes_md": notes_text,
|
|
"version_label": label_text,
|
|
}
|
|
url = urllib.parse.urljoin(clean_base_url + "/", "api/v1/generations")
|
|
data = post_multipart(
|
|
url,
|
|
clean_workspace_token,
|
|
fields,
|
|
[("image", ("generation.png", tensor_to_png_bytes(image), "image/png")), *input_image_files],
|
|
)
|
|
out_asset_id = data.get("asset", {}).get("id", "")
|
|
out_version_id = data.get("version", {}).get("id", "")
|
|
_log_ok("SaveToGenAsset", asset_id=out_asset_id, version_id=out_version_id, token_source=token_source)
|
|
return (image, out_asset_id, out_version_id, json.dumps(data, indent=2))
|
|
except Exception as exc:
|
|
_log_error("SaveToGenAsset", exc)
|
|
status = {"error": str(exc)}
|
|
return (image, "", "", json.dumps(status, indent=2))
|
|
|
|
|
|
class GenAssetLoadVersion:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"tooltip": "Optional. Exact asset id. Empty means latest asset in this workspace.",
|
|
},
|
|
),
|
|
"version_id": (
|
|
"STRING",
|
|
{"default": "", "tooltip": "Optional. When set, this exact version id is loaded."},
|
|
),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("image", "asset_id", "version_id", "workflow_json", "metadata_json", "status_json")
|
|
FUNCTION = "load"
|
|
CATEGORY = CATEGORY
|
|
|
|
def load(self, base_url: str, token: str, asset_id: str, version_id: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
explicit_version_id = version_id.strip()
|
|
root = clean_base_url + "/"
|
|
load_mode = "latest"
|
|
selected_asset_id = ""
|
|
selected_version_id = ""
|
|
|
|
if explicit_version_id:
|
|
load_mode = "version_id"
|
|
path = f"api/v1/versions/{urllib.parse.quote(explicit_version_id)}/load"
|
|
url = urllib.parse.urljoin(root, path)
|
|
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {clean_workspace_token}"})
|
|
data = read_json(request)
|
|
version = data.get("version") or {}
|
|
selected_asset_id = str((data.get("asset") or {}).get("id") or version.get("asset_id") or "")
|
|
selected_version_id = str(version.get("id") or explicit_version_id)
|
|
else:
|
|
if asset_id_text:
|
|
load_mode = "asset_id"
|
|
asset_url = urllib.parse.urljoin(root, f"api/v1/assets/{urllib.parse.quote(asset_id_text)}")
|
|
asset_request = urllib.request.Request(asset_url, headers={"Authorization": f"Bearer {clean_workspace_token}"})
|
|
asset_data = read_json(asset_request)
|
|
asset = asset_data.get("asset") or {}
|
|
versions = asset_data.get("versions") or []
|
|
current_id = asset.get("current_version_id")
|
|
version = next((item for item in versions if item.get("id") == current_id), versions[0] if versions else {})
|
|
selected_asset_id = str(asset.get("id") or asset_id_text)
|
|
else:
|
|
load_mode = "latest"
|
|
path = "api/v1/assets"
|
|
assets_url = urllib.parse.urljoin(root, path)
|
|
assets_request = urllib.request.Request(assets_url, headers={"Authorization": f"Bearer {clean_workspace_token}"})
|
|
assets_data = read_json(assets_request)
|
|
assets = assets_data.get("assets") or []
|
|
if not assets:
|
|
raise RuntimeError("No assets found in this workspace.")
|
|
asset = assets[0]
|
|
version = asset.get("current_version") or {}
|
|
selected_asset_id = str(asset.get("id", ""))
|
|
|
|
selected_version_id = str(version.get("id", ""))
|
|
data = {
|
|
"asset": {"id": selected_asset_id, "name": str(asset.get("name", "")), "version_count": asset.get("version_count", 0)},
|
|
"version": {
|
|
"id": selected_version_id,
|
|
"version_number": version.get("version_number", 0),
|
|
"workflow_json": version.get("workflow_json"),
|
|
"metadata": version.get("metadata"),
|
|
},
|
|
}
|
|
|
|
preview_url = (data.get("version") or {}).get("signed_preview_url") or version.get("signed_preview_url")
|
|
if not preview_url:
|
|
raise RuntimeError("Matched version did not include a signed preview URL.")
|
|
image = pil_to_tensor(download_image(preview_url))
|
|
workflow_json = json.dumps((data.get("version") or {}).get("workflow_json") or version.get("workflow_json") or {}, indent=2)
|
|
metadata_json = json.dumps((data.get("version") or {}).get("metadata") or version.get("metadata") or {}, indent=2)
|
|
status = {
|
|
"asset": {"id": selected_asset_id},
|
|
"version": {"id": selected_version_id},
|
|
"matched_asset_id": asset_id_text,
|
|
"load_mode": load_mode,
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
"defaults": {"version_id_optional": True, "asset_id_optional": True, "empty_asset_id_loads": "latest_asset"},
|
|
}
|
|
return (
|
|
image,
|
|
selected_asset_id,
|
|
selected_version_id,
|
|
workflow_json,
|
|
metadata_json,
|
|
json.dumps(status, indent=2),
|
|
)
|
|
except Exception as exc:
|
|
_log_error("LoadAssetFromGenAsset", exc)
|
|
status = {"error": str(exc)}
|
|
return (blank_image(), "", "", "{}", "{}", json.dumps(status, indent=2))
|
|
|
|
|
|
class GenAssetLoadExactVersion:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"version_id": ("STRING", {"default": "", "tooltip": "Required. Exact GenAsset version id (UUID)."}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("image", "asset_id", "version_id", "workflow_json", "metadata_json", "status_json")
|
|
FUNCTION = "load_exact"
|
|
CATEGORY = CATEGORY
|
|
|
|
def load_exact(self, base_url: str, token: str, version_id: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
explicit_version_id = version_id.strip()
|
|
if not explicit_version_id:
|
|
raise RuntimeError("Paste a version_id to load one exact version.")
|
|
|
|
data = load_version_payload(clean_base_url, clean_workspace_token, explicit_version_id)
|
|
version = data.get("version") or {}
|
|
preview_url = version.get("signed_preview_url")
|
|
if not preview_url:
|
|
raise RuntimeError("Matched version did not include a signed preview URL.")
|
|
image = pil_to_tensor(download_image(preview_url))
|
|
asset_id = str((data.get("asset") or {}).get("id") or version.get("asset_id") or "")
|
|
version_out = str(version.get("id") or explicit_version_id)
|
|
workflow_json = json.dumps(version.get("workflow_json") or {}, indent=2)
|
|
metadata_json = json.dumps(version.get("metadata") or {}, indent=2)
|
|
status = {
|
|
"asset": {"id": asset_id},
|
|
"version": {"id": version_out},
|
|
"load_mode": "exact_version",
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
return (image, asset_id, version_out, workflow_json, metadata_json, json.dumps(status, indent=2))
|
|
except Exception as exc:
|
|
_log_error("LoadVersionFromGenAsset", exc)
|
|
status = {"error": str(exc)}
|
|
return (blank_image(), "", "", "{}", "{}", json.dumps(status, indent=2))
|
|
|
|
|
|
class GenAssetPatchVersionMetadata:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"version_id": ("STRING", {"default": "", "tooltip": "Version id to patch."}),
|
|
"metadata_patch_json": (
|
|
"STRING",
|
|
{"default": "{\n \"approval\": \"approved\"\n}", "multiline": True},
|
|
),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("version_id", "metadata_json", "status_json")
|
|
FUNCTION = "patch"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def patch(self, base_url: str, token: str, version_id: str, metadata_patch_json: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
version_text = version_id.strip()
|
|
if not version_text:
|
|
raise RuntimeError("Paste a version_id to patch metadata.")
|
|
metadata_patch = parse_json(metadata_patch_json, None)
|
|
if not isinstance(metadata_patch, dict):
|
|
raise RuntimeError("metadata_patch_json must be a JSON object.")
|
|
path = f"api/v1/versions/{urllib.parse.quote(version_text)}/metadata"
|
|
url = urllib.parse.urljoin(clean_base_url + "/", path)
|
|
data = request_json("PATCH", url, clean_workspace_token, {"metadata": metadata_patch})
|
|
version = data.get("version") or {}
|
|
metadata_json = json.dumps(version.get("metadata") or {}, indent=2)
|
|
status = {
|
|
"ok": True,
|
|
"version": {"id": str(version.get("id") or version_text)},
|
|
"patched_keys": sorted(metadata_patch.keys()),
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
summary = f"SUCCESS: Metadata patched for version {str(version.get('id') or version_text)}."
|
|
summary += key_loaded_note(token_source, token_source_ref)
|
|
status_json = json.dumps(status, indent=2)
|
|
_log_ok("SaveMetadataPatchToGenAsset", version_id=str(version.get("id") or version_text))
|
|
return {"ui": {"text": [summary]}, "result": (str(version.get("id") or version_text), metadata_json, status_json)}
|
|
except Exception as exc:
|
|
_log_error("SaveMetadataPatchToGenAsset", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
summary = f"ERROR: {status['error']}"
|
|
status_json = json.dumps(status, indent=2)
|
|
return {"ui": {"text": [summary]}, "result": ("", "{}", status_json)}
|
|
|
|
|
|
class GenAssetCompareVersions:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"left_version_id": ("STRING", {"default": "", "tooltip": "Left/older version id."}),
|
|
"right_version_id": ("STRING", {"default": "", "tooltip": "Right/newer version id."}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE", "STRING", "STRING")
|
|
RETURN_NAMES = ("left_image", "right_image", "diff_json", "status_json")
|
|
FUNCTION = "compare"
|
|
CATEGORY = CATEGORY
|
|
|
|
def compare(self, base_url: str, token: str, left_version_id: str, right_version_id: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
left_id = left_version_id.strip()
|
|
right_id = right_version_id.strip()
|
|
if not left_id or not right_id:
|
|
raise RuntimeError("Paste both left_version_id and right_version_id.")
|
|
|
|
left_data = load_version_payload(clean_base_url, clean_workspace_token, left_id)
|
|
right_data = load_version_payload(clean_base_url, clean_workspace_token, right_id)
|
|
left_version = left_data.get("version") or {}
|
|
right_version = right_data.get("version") or {}
|
|
left_preview = left_version.get("signed_preview_url")
|
|
right_preview = right_version.get("signed_preview_url")
|
|
if not left_preview or not right_preview:
|
|
raise RuntimeError("One or both versions did not include a signed preview URL.")
|
|
|
|
left_image = pil_to_tensor(download_image(left_preview))
|
|
right_image = pil_to_tensor(download_image(right_preview))
|
|
|
|
left_meta = left_version.get("metadata") if isinstance(left_version.get("metadata"), dict) else {}
|
|
right_meta = right_version.get("metadata") if isinstance(right_version.get("metadata"), dict) else {}
|
|
left_perf = left_meta.get("performance") if isinstance(left_meta, dict) and isinstance(left_meta.get("performance"), dict) else {}
|
|
right_perf = right_meta.get("performance") if isinstance(right_meta, dict) and isinstance(right_meta.get("performance"), dict) else {}
|
|
left_model = pick_string(left_version.get("model"), pick_string(pick_dict(left_meta, "model").get("checkpoint")))
|
|
right_model = pick_string(right_version.get("model"), pick_string(pick_dict(right_meta, "model").get("checkpoint")))
|
|
|
|
diff = {
|
|
"left": {
|
|
"version_id": str(left_version.get("id") or left_id),
|
|
"version_number": left_version.get("version_number"),
|
|
"asset_id": left_version.get("asset_id"),
|
|
"prompt": left_version.get("prompt"),
|
|
"negative_prompt": left_version.get("negative_prompt"),
|
|
"model": left_model,
|
|
"seed": left_version.get("seed"),
|
|
"sampler_name": left_perf.get("sampler_name"),
|
|
"scheduler": left_perf.get("scheduler"),
|
|
"steps": left_perf.get("steps"),
|
|
"cfg": left_perf.get("cfg"),
|
|
"denoise": left_perf.get("denoise"),
|
|
},
|
|
"right": {
|
|
"version_id": str(right_version.get("id") or right_id),
|
|
"version_number": right_version.get("version_number"),
|
|
"asset_id": right_version.get("asset_id"),
|
|
"prompt": right_version.get("prompt"),
|
|
"negative_prompt": right_version.get("negative_prompt"),
|
|
"model": right_model,
|
|
"seed": right_version.get("seed"),
|
|
"sampler_name": right_perf.get("sampler_name"),
|
|
"scheduler": right_perf.get("scheduler"),
|
|
"steps": right_perf.get("steps"),
|
|
"cfg": right_perf.get("cfg"),
|
|
"denoise": right_perf.get("denoise"),
|
|
},
|
|
"changed": {
|
|
"prompt": left_version.get("prompt") != right_version.get("prompt"),
|
|
"negative_prompt": left_version.get("negative_prompt") != right_version.get("negative_prompt"),
|
|
"model": left_model != right_model,
|
|
"seed": left_version.get("seed") != right_version.get("seed"),
|
|
"sampler_name": left_perf.get("sampler_name") != right_perf.get("sampler_name"),
|
|
"scheduler": left_perf.get("scheduler") != right_perf.get("scheduler"),
|
|
"steps": left_perf.get("steps") != right_perf.get("steps"),
|
|
"cfg": left_perf.get("cfg") != right_perf.get("cfg"),
|
|
"denoise": left_perf.get("denoise") != right_perf.get("denoise"),
|
|
},
|
|
}
|
|
status = {
|
|
"ok": True,
|
|
"left_version_id": str(left_version.get("id") or left_id),
|
|
"right_version_id": str(right_version.get("id") or right_id),
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
return (left_image, right_image, json.dumps(diff, indent=2), json.dumps(status, indent=2))
|
|
except Exception as exc:
|
|
_log_error("CompareTwoGenAssetVersions", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
return (blank_image(), blank_image(), "{}", json.dumps(status, indent=2))
|
|
|
|
|
|
class GenAssetCreateBranchVersion:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Target asset id to append a branched version."}),
|
|
"parent_version_id": ("STRING", {"default": "", "tooltip": "Parent/source version id for this branch."}),
|
|
"asset_name": ("STRING", {"default": "", "tooltip": "Optional fallback if asset_id is empty."}),
|
|
"prompt_text": ("STRING", {"default": "", "multiline": True}),
|
|
"negative_prompt_text": ("STRING", {"default": "", "multiline": True}),
|
|
"model_name": ("STRING", {"default": ""}),
|
|
"seed": ("INT", {"default": 0, "min": 0}),
|
|
"tags_csv": ("STRING", {"default": ""}),
|
|
"intent": ("STRING", {"default": "branch_edit"}),
|
|
"extra_metadata_json": ("STRING", {"default": "{}", "multiline": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("image", "asset_id", "version_id", "status_json")
|
|
FUNCTION = "create"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def create(
|
|
self,
|
|
image: torch.Tensor,
|
|
base_url: str,
|
|
token: str,
|
|
asset_id: str,
|
|
parent_version_id: str,
|
|
asset_name: str,
|
|
prompt_text: str,
|
|
negative_prompt_text: str,
|
|
model_name: str,
|
|
seed: int,
|
|
tags_csv: str,
|
|
intent: str,
|
|
extra_metadata_json: str,
|
|
):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
parent_version_text = parent_version_id.strip()
|
|
if not parent_version_text:
|
|
raise RuntimeError("Paste parent_version_id to create a branch version.")
|
|
if parent_version_text == "PASTE_PARENT_VERSION_ID":
|
|
raise RuntimeError("Replace PASTE_PARENT_VERSION_ID with a real parent version id.")
|
|
if not looks_like_uuid(parent_version_text):
|
|
raise RuntimeError("parent_version_id must be a version UUID.")
|
|
if not asset_id_text and not asset_name.strip():
|
|
raise RuntimeError("Set asset_id or asset_name for branch save.")
|
|
if asset_id_text and not looks_like_uuid(asset_id_text):
|
|
raise RuntimeError("asset_id must be an asset UUID.")
|
|
|
|
extra_meta = parse_json(extra_metadata_json, {})
|
|
if not isinstance(extra_meta, dict):
|
|
raise RuntimeError("extra_metadata_json must be a JSON object.")
|
|
metadata = {
|
|
**extra_meta,
|
|
"branch": {
|
|
"parent_version_id": parent_version_text,
|
|
"created_in": "comfyui",
|
|
},
|
|
}
|
|
fields = {
|
|
"asset_name": asset_name.strip() or "Untitled asset",
|
|
"asset_id": asset_id_text,
|
|
"prompt": prompt_text,
|
|
"negative_prompt": negative_prompt_text,
|
|
"workflow_json": json.dumps({}),
|
|
"model": model_name.strip(),
|
|
"seed": str(int(seed)),
|
|
"tags": tags_csv,
|
|
"intent": intent.strip() or "branch_edit",
|
|
"metadata": json.dumps(metadata),
|
|
"source": "comfyui-branch",
|
|
}
|
|
url = urllib.parse.urljoin(clean_base_url + "/", "api/v1/generations")
|
|
data = post_multipart(
|
|
url,
|
|
clean_workspace_token,
|
|
fields,
|
|
{"image": ("branch_generation.png", tensor_to_png_bytes(image), "image/png")},
|
|
)
|
|
out_asset_id = str((data.get("asset") or {}).get("id") or asset_id_text)
|
|
out_version_id = str((data.get("version") or {}).get("id") or "")
|
|
status = {
|
|
"ok": True,
|
|
"asset_id": out_asset_id,
|
|
"version_id": out_version_id,
|
|
"parent_version_id": parent_version_text,
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
"message": "Branch version created.",
|
|
}
|
|
summary = f"SUCCESS: Branch version created ({out_version_id}) on asset {out_asset_id}."
|
|
summary += key_loaded_note(token_source, token_source_ref)
|
|
status_json = json.dumps(status, indent=2)
|
|
_log_ok("CreateBranchVersionInGenAsset", asset_id=out_asset_id, version_id=out_version_id)
|
|
return {"ui": {"text": [summary]}, "result": (image, out_asset_id, out_version_id, status_json)}
|
|
except Exception as exc:
|
|
_log_error("CreateBranchVersionInGenAsset", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
summary = f"ERROR: {status['error']}"
|
|
status_json = json.dumps(status, indent=2)
|
|
return {"ui": {"text": [summary]}, "result": (image, "", "", status_json)}
|
|
|
|
|
|
class GenAssetLoadRecipeToWidgets:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"workflow_json": ("STRING", {"default": "{}", "multiline": True}),
|
|
"metadata_json": ("STRING", {"default": "{}", "multiline": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "INT", "INT", "FLOAT", "STRING", "STRING", "FLOAT", "INT", "INT", "STRING")
|
|
RETURN_NAMES = (
|
|
"prompt_text",
|
|
"negative_prompt_text",
|
|
"model_name",
|
|
"seed",
|
|
"steps",
|
|
"cfg",
|
|
"sampler_name",
|
|
"scheduler",
|
|
"denoise",
|
|
"width",
|
|
"height",
|
|
"status_json",
|
|
)
|
|
FUNCTION = "extract"
|
|
CATEGORY = CATEGORY
|
|
|
|
def extract(self, workflow_json: str, metadata_json: str):
|
|
try:
|
|
workflow = parse_json(workflow_json, {})
|
|
metadata = parse_json(metadata_json, {})
|
|
if not isinstance(workflow, dict):
|
|
workflow = {}
|
|
if not isinstance(metadata, dict):
|
|
metadata = {}
|
|
|
|
api_prompt = pick_dict(workflow, "api_prompt")
|
|
pos_prompt, neg_prompt = extract_prompt_pair_from_api_prompt(api_prompt)
|
|
|
|
performance = pick_dict(metadata, "performance")
|
|
model_info = pick_dict(metadata, "model")
|
|
image_info = pick_dict(metadata, "image")
|
|
|
|
prompt_out = pick_string(pos_prompt, metadata.get("prompt"), metadata.get("positive_prompt"))
|
|
negative_out = pick_string(neg_prompt, metadata.get("negative_prompt"))
|
|
model_out = pick_string(model_info.get("checkpoint"), metadata.get("model"))
|
|
seed_out = pick_int(performance.get("seed"), metadata.get("seed"))
|
|
steps_out = pick_int(performance.get("steps"), metadata.get("steps"))
|
|
cfg_out = pick_float(performance.get("cfg"), metadata.get("cfg"))
|
|
sampler_out = pick_string(performance.get("sampler_name"), metadata.get("sampler_name"))
|
|
scheduler_out = pick_string(performance.get("scheduler"), metadata.get("scheduler"))
|
|
denoise_out = pick_float(performance.get("denoise"), metadata.get("denoise"))
|
|
width_out = pick_int(image_info.get("width"), metadata.get("width"))
|
|
height_out = pick_int(image_info.get("height"), metadata.get("height"))
|
|
|
|
status = {
|
|
"ok": True,
|
|
"extracted": {
|
|
"prompt": bool(prompt_out),
|
|
"negative_prompt": bool(negative_out),
|
|
"model": bool(model_out),
|
|
"seed": bool(seed_out),
|
|
"steps": bool(steps_out),
|
|
"cfg": bool(cfg_out),
|
|
"sampler_name": bool(sampler_out),
|
|
"scheduler": bool(scheduler_out),
|
|
"denoise": bool(denoise_out),
|
|
"width": bool(width_out),
|
|
"height": bool(height_out),
|
|
},
|
|
}
|
|
_log_ok(
|
|
"LoadRecipeToWidgets",
|
|
model_name=model_out,
|
|
seed=int(seed_out),
|
|
width=int(width_out),
|
|
height=int(height_out),
|
|
)
|
|
return (
|
|
prompt_out,
|
|
negative_out,
|
|
model_out,
|
|
int(seed_out),
|
|
int(steps_out),
|
|
float(cfg_out),
|
|
sampler_out,
|
|
scheduler_out,
|
|
float(denoise_out),
|
|
int(width_out),
|
|
int(height_out),
|
|
json.dumps(status, indent=2),
|
|
)
|
|
except Exception as exc:
|
|
_log_error("LoadRecipeToWidgets", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
return ("", "", "", 0, 0, 0.0, "", "", 0.0, 0, 0, json.dumps(status, indent=2))
|
|
|
|
|
|
class GenAssetFindAssets:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"search_query": ("STRING", {"default": "", "tooltip": "Name/tags/prompt keyword search. Empty lists recent assets."}),
|
|
"page": ("INT", {"default": 1, "min": 1, "max": 9999}),
|
|
"page_size": ("INT", {"default": 20, "min": 1, "max": 64}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("assets_json", "asset_ids_csv", "status_json")
|
|
FUNCTION = "search"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def search(self, base_url: str, token: str, search_query: str, page: int, page_size: int):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
query: list[tuple[str, str]] = [("page", str(int(max(1, page)))), ("page_size", str(int(max(1, min(64, page_size)))))]
|
|
if search_query.strip():
|
|
query.append(("search", search_query.strip()))
|
|
url = urllib.parse.urljoin(clean_base_url + "/", "api/v1/assets")
|
|
if query:
|
|
url = f"{url}?{urllib.parse.urlencode(query)}"
|
|
data = request_json("GET", url, clean_workspace_token)
|
|
assets = data.get("assets") if isinstance(data.get("assets"), list) else []
|
|
compact = [compact_asset_row(item) for item in assets if isinstance(item, dict)]
|
|
asset_ids = [item["id"] for item in compact if item.get("id")]
|
|
status = {
|
|
"ok": True,
|
|
"count": len(compact),
|
|
"search_query": search_query.strip(),
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
return (json.dumps(compact, indent=2), ", ".join(asset_ids), json.dumps(status, indent=2))
|
|
except Exception as exc:
|
|
_log_error("FindAssetsInGenAsset", exc)
|
|
return ("[]", "", json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
|
|
|
|
class GenAssetListAssetVersions:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Required asset id (UUID)."}),
|
|
"max_versions": ("INT", {"default": 20, "min": 1, "max": 200}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("versions_json", "version_ids_csv", "status_json")
|
|
FUNCTION = "list_versions"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def list_versions(self, base_url: str, token: str, asset_id: str, max_versions: int):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
if not asset_id_text:
|
|
raise RuntimeError("Paste asset_id to list versions.")
|
|
path = f"api/v1/assets/{urllib.parse.quote(asset_id_text)}"
|
|
url = urllib.parse.urljoin(clean_base_url + "/", path)
|
|
data = request_json("GET", url, clean_workspace_token)
|
|
versions = data.get("versions") if isinstance(data.get("versions"), list) else []
|
|
sorted_versions = sorted(
|
|
[item for item in versions if isinstance(item, dict)],
|
|
key=lambda item: int(item.get("version_number") or 0),
|
|
reverse=True,
|
|
)
|
|
limited = sorted_versions[: int(max(1, max_versions))]
|
|
compact = [compact_version_row(item) for item in limited]
|
|
version_ids = [item["id"] for item in compact if item.get("id")]
|
|
status = {
|
|
"ok": True,
|
|
"asset_id": asset_id_text,
|
|
"count": len(compact),
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
return (json.dumps(compact, indent=2), ", ".join(version_ids), json.dumps(status, indent=2))
|
|
except Exception as exc:
|
|
_log_error("ListAssetVersionsInGenAsset", exc)
|
|
return ("[]", "", json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
|
|
|
|
class GenAssetLoadCurrentVersion:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Required asset id (UUID)."}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("image", "asset_id", "version_id", "workflow_json", "metadata_json", "status_json")
|
|
FUNCTION = "load_current"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def load_current(self, base_url: str, token: str, asset_id: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
if not asset_id_text:
|
|
raise RuntimeError("Paste asset_id to load current version.")
|
|
path = f"api/v1/assets/{urllib.parse.quote(asset_id_text)}"
|
|
url = urllib.parse.urljoin(clean_base_url + "/", path)
|
|
data = request_json("GET", url, clean_workspace_token)
|
|
asset = data.get("asset") if isinstance(data.get("asset"), dict) else {}
|
|
versions = data.get("versions") if isinstance(data.get("versions"), list) else []
|
|
current_version_id = str(asset.get("current_version_id") or "")
|
|
if current_version_id:
|
|
version = next((v for v in versions if isinstance(v, dict) and str(v.get("id") or "") == current_version_id), None)
|
|
else:
|
|
sorted_versions = sorted(
|
|
[item for item in versions if isinstance(item, dict)],
|
|
key=lambda item: int(item.get("version_number") or 0),
|
|
reverse=True,
|
|
)
|
|
version = sorted_versions[0] if sorted_versions else None
|
|
if not isinstance(version, dict):
|
|
raise RuntimeError("No versions found for this asset.")
|
|
version_id = str(version.get("id") or "")
|
|
if not version_id:
|
|
raise RuntimeError("Matched version has no id.")
|
|
payload = load_version_payload(clean_base_url, clean_workspace_token, version_id)
|
|
loaded_version = payload.get("version") if isinstance(payload.get("version"), dict) else version
|
|
preview_url = str(loaded_version.get("signed_preview_url") or "")
|
|
if not preview_url:
|
|
raise RuntimeError("Current version did not include a signed preview URL.")
|
|
image = pil_to_tensor(download_image(preview_url))
|
|
workflow_json = json.dumps(loaded_version.get("workflow_json") or {}, indent=2)
|
|
metadata_json = json.dumps(loaded_version.get("metadata") or {}, indent=2)
|
|
status = {
|
|
"ok": True,
|
|
"asset_id": asset_id_text,
|
|
"version_id": version_id,
|
|
"load_mode": "current_version",
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
return (image, asset_id_text, version_id, workflow_json, metadata_json, json.dumps(status, indent=2))
|
|
except Exception as exc:
|
|
_log_error("LoadCurrentVersionForAsset", exc)
|
|
return (blank_image(), "", "", "{}", "{}", json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
|
|
|
|
class GenAssetPromoteVersion:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Asset id (UUID)."}),
|
|
"version_id": ("STRING", {"default": "", "tooltip": "Version id to set as current."}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("asset_id", "version_id", "status_json")
|
|
FUNCTION = "promote"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def promote(self, base_url: str, token: str, asset_id: str, version_id: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
version_id_text = version_id.strip()
|
|
if not asset_id_text:
|
|
raise RuntimeError("Paste asset_id.")
|
|
if not version_id_text:
|
|
raise RuntimeError("Paste version_id.")
|
|
path = f"api/v1/assets/{urllib.parse.quote(asset_id_text)}"
|
|
url = urllib.parse.urljoin(clean_base_url + "/", path)
|
|
data = request_json("PATCH", url, clean_workspace_token, {"current_version_id": version_id_text})
|
|
status = {
|
|
"ok": True,
|
|
"asset_id": asset_id_text,
|
|
"version_id": version_id_text,
|
|
"asset": compact_asset_row((data.get("asset") or {}) if isinstance(data.get("asset"), dict) else {}),
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
summary = f"SUCCESS: asset {asset_id_text} now points to version {version_id_text}."
|
|
summary += key_loaded_note(token_source, token_source_ref)
|
|
_log_ok("PromoteVersionInGenAsset", asset_id=asset_id_text, version_id=version_id_text)
|
|
return {"ui": {"text": [summary]}, "result": (asset_id_text, version_id_text, json.dumps(status, indent=2))}
|
|
except Exception as exc:
|
|
_log_error("PromoteVersionInGenAsset", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
summary = f"ERROR: {status['error']}"
|
|
return {"ui": {"text": [summary]}, "result": ("", "", json.dumps(status, indent=2))}
|
|
|
|
|
|
class GenAssetDeleteVersion:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"version_id": ("STRING", {"default": "", "tooltip": "Version id to delete."}),
|
|
"confirm_delete": ("BOOLEAN", {"default": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("version_id", "status_json")
|
|
FUNCTION = "delete"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def delete(self, base_url: str, token: str, version_id: str, confirm_delete: bool):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, _, _ = resolve_workspace_token(token)
|
|
version_id_text = version_id.strip()
|
|
if not version_id_text:
|
|
raise RuntimeError("Paste version_id.")
|
|
if not confirm_delete:
|
|
raise RuntimeError("Set confirm_delete=true to delete a version.")
|
|
path = f"api/v1/versions/{urllib.parse.quote(version_id_text)}"
|
|
url = urllib.parse.urljoin(clean_base_url + "/", path)
|
|
data = request_json("DELETE", url, clean_workspace_token)
|
|
_log_ok("DeleteVersionInGenAsset", version_id=version_id_text)
|
|
return {"ui": {"text": [f"SUCCESS: deleted version {version_id_text}."]}, "result": (version_id_text, json.dumps({"ok": True, "result": data}, indent=2))}
|
|
except Exception as exc:
|
|
_log_error("DeleteVersionInGenAsset", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
return {"ui": {"text": [f"ERROR: {status['error']}"]}, "result": ("", json.dumps(status, indent=2))}
|
|
|
|
|
|
class GenAssetForkAssetFromVersion:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"source_version_id": ("STRING", {"default": "", "tooltip": "Existing version id to fork from."}),
|
|
"new_asset_name": ("STRING", {"default": "Forked Asset"}),
|
|
"prompt_suffix": ("STRING", {"default": "forked in comfyui", "multiline": True}),
|
|
"negative_prompt_override": ("STRING", {"default": "", "multiline": True}),
|
|
"tags_csv": ("STRING", {"default": "forked"}),
|
|
"intent": ("STRING", {"default": "fork"}),
|
|
"extra_metadata_json": ("STRING", {"default": "{\n \"fork\": true\n}", "multiline": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("image", "asset_id", "version_id", "status_json")
|
|
FUNCTION = "fork"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def fork(
|
|
self,
|
|
base_url: str,
|
|
token: str,
|
|
source_version_id: str,
|
|
new_asset_name: str,
|
|
prompt_suffix: str,
|
|
negative_prompt_override: str,
|
|
tags_csv: str,
|
|
intent: str,
|
|
extra_metadata_json: str,
|
|
):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, _ = resolve_workspace_token(token)
|
|
source_version_text = source_version_id.strip()
|
|
if not source_version_text:
|
|
raise RuntimeError("Paste source_version_id.")
|
|
if not new_asset_name.strip():
|
|
raise RuntimeError("Set new_asset_name.")
|
|
extra_meta = parse_json(extra_metadata_json, {})
|
|
if not isinstance(extra_meta, dict):
|
|
raise RuntimeError("extra_metadata_json must be a JSON object.")
|
|
source_payload = load_version_payload(clean_base_url, clean_workspace_token, source_version_text)
|
|
source_version = source_payload.get("version") if isinstance(source_payload.get("version"), dict) else {}
|
|
preview_url = str(source_version.get("signed_preview_url") or "")
|
|
if not preview_url:
|
|
raise RuntimeError("Source version missing signed_preview_url.")
|
|
image_pil = download_image(preview_url)
|
|
image = pil_to_tensor(image_pil)
|
|
prompt_text = str(source_version.get("prompt") or "").strip()
|
|
suffix = prompt_suffix.strip()
|
|
if suffix:
|
|
prompt_text = f"{prompt_text}, {suffix}" if prompt_text else suffix
|
|
negative_prompt = negative_prompt_override.strip() or str(source_version.get("negative_prompt") or "")
|
|
model_name = str(source_version.get("model") or "")
|
|
seed_value = int(source_version.get("seed") or 0)
|
|
merged_metadata = {
|
|
**extra_meta,
|
|
"fork": {
|
|
"from_version_id": source_version_text,
|
|
"from_asset_id": str(source_version.get("asset_id") or ""),
|
|
"created_in": "comfyui",
|
|
},
|
|
}
|
|
fields = {
|
|
"asset_name": new_asset_name.strip(),
|
|
"asset_id": "",
|
|
"prompt": prompt_text,
|
|
"negative_prompt": negative_prompt,
|
|
"workflow_json": json.dumps(source_version.get("workflow_json") or {}),
|
|
"model": model_name,
|
|
"seed": str(seed_value),
|
|
"tags": tags_csv,
|
|
"intent": intent.strip() or "fork",
|
|
"metadata": json.dumps(merged_metadata),
|
|
"source": "comfyui-fork",
|
|
}
|
|
data = post_multipart(
|
|
urllib.parse.urljoin(clean_base_url + "/", "api/v1/generations"),
|
|
clean_workspace_token,
|
|
fields,
|
|
{"image": ("forked_version.png", tensor_to_png_bytes(image), "image/png")},
|
|
)
|
|
out_asset_id = str((data.get("asset") or {}).get("id") or "")
|
|
out_version_id = str((data.get("version") or {}).get("id") or "")
|
|
_log_ok("ForkAssetFromVersionInGenAsset", asset_id=out_asset_id, version_id=out_version_id, token_source=token_source)
|
|
return (image, out_asset_id, out_version_id, json.dumps({"ok": True, "source_version_id": source_version_text, "asset_id": out_asset_id, "version_id": out_version_id}, indent=2))
|
|
except Exception as exc:
|
|
_log_error("ForkAssetFromVersionInGenAsset", exc)
|
|
return (blank_image(), "", "", json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
|
|
|
|
class GenAssetCreateAsset:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_name": ("STRING", {"default": "New Asset"}),
|
|
"prompt_text": ("STRING", {"default": "", "multiline": True}),
|
|
"negative_prompt_text": ("STRING", {"default": "", "multiline": True}),
|
|
"model_name": ("STRING", {"default": ""}),
|
|
"seed": ("INT", {"default": 0, "min": 0}),
|
|
"tags_csv": ("STRING", {"default": ""}),
|
|
"intent": ("STRING", {"default": "create_asset"}),
|
|
"extra_metadata_json": ("STRING", {"default": "{}", "multiline": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("image", "asset_id", "version_id", "status_json")
|
|
FUNCTION = "create_asset"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def create_asset(
|
|
self,
|
|
image: torch.Tensor,
|
|
base_url: str,
|
|
token: str,
|
|
asset_name: str,
|
|
prompt_text: str,
|
|
negative_prompt_text: str,
|
|
model_name: str,
|
|
seed: int,
|
|
tags_csv: str,
|
|
intent: str,
|
|
extra_metadata_json: str,
|
|
):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, _ = resolve_workspace_token(token)
|
|
asset_name_text = asset_name.strip()
|
|
if not asset_name_text:
|
|
raise RuntimeError("Set asset_name.")
|
|
metadata = parse_json(extra_metadata_json, {})
|
|
if not isinstance(metadata, dict):
|
|
raise RuntimeError("extra_metadata_json must be a JSON object.")
|
|
fields = {
|
|
"asset_name": asset_name_text,
|
|
"asset_id": "",
|
|
"prompt": prompt_text,
|
|
"negative_prompt": negative_prompt_text,
|
|
"workflow_json": "{}",
|
|
"model": model_name,
|
|
"seed": str(int(seed)),
|
|
"tags": tags_csv,
|
|
"intent": intent.strip() or "create_asset",
|
|
"metadata": json.dumps(metadata),
|
|
"source": "comfyui-create",
|
|
}
|
|
data = post_multipart(
|
|
urllib.parse.urljoin(clean_base_url + "/", "api/v1/generations"),
|
|
clean_workspace_token,
|
|
fields,
|
|
{"image": ("create_asset.png", tensor_to_png_bytes(image), "image/png")},
|
|
)
|
|
out_asset_id = str((data.get("asset") or {}).get("id") or "")
|
|
out_version_id = str((data.get("version") or {}).get("id") or "")
|
|
_log_ok("CreateAssetInGenAsset", asset_id=out_asset_id, version_id=out_version_id, token_source=token_source)
|
|
return (image, out_asset_id, out_version_id, json.dumps({"ok": True, "asset_id": out_asset_id, "version_id": out_version_id}, indent=2))
|
|
except Exception as exc:
|
|
_log_error("CreateAssetInGenAsset", exc)
|
|
return (image, "", "", json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
|
|
|
|
class GenAssetRenameAsset:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Asset id to rename."}),
|
|
"new_name": ("STRING", {"default": ""}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("asset_id", "asset_name", "status_json")
|
|
FUNCTION = "rename"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def rename(self, base_url: str, token: str, asset_id: str, new_name: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
name_text = new_name.strip()
|
|
if not asset_id_text:
|
|
raise RuntimeError("Paste asset_id.")
|
|
if not name_text:
|
|
raise RuntimeError("Set new_name.")
|
|
path = f"api/v1/assets/{urllib.parse.quote(asset_id_text)}"
|
|
data = request_json("PATCH", urllib.parse.urljoin(clean_base_url + "/", path), clean_workspace_token, {"name": name_text})
|
|
asset = (data.get("asset") or {}) if isinstance(data.get("asset"), dict) else {}
|
|
out_name = str(asset.get("name") or name_text)
|
|
status = {"ok": True, "asset_id": asset_id_text, "asset_name": out_name, "token_source": token_source, "token_source_ref": token_source_ref}
|
|
_log_ok("RenameAssetInGenAsset", asset_id=asset_id_text)
|
|
return {"ui": {"text": [f"SUCCESS: renamed asset to '{out_name}'." ]}, "result": (asset_id_text, out_name, json.dumps(status, indent=2))}
|
|
except Exception as exc:
|
|
_log_error("RenameAssetInGenAsset", exc)
|
|
status = {"ok": False, "error": str(exc)}
|
|
return {"ui": {"text": [f"ERROR: {status['error']}"]}, "result": ("", "", json.dumps(status, indent=2))}
|
|
|
|
|
|
class GenAssetUpsertAssetFields:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Asset id to patch."}),
|
|
"tags_csv": ("STRING", {"default": ""}),
|
|
"notes_md": ("STRING", {"default": "", "multiline": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("asset_id", "asset_json", "status_json")
|
|
FUNCTION = "upsert"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def upsert(self, base_url: str, token: str, asset_id: str, tags_csv: str, notes_md: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, token_source, token_source_ref = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
if not asset_id_text:
|
|
raise RuntimeError("Paste asset_id.")
|
|
tags = parse_tags_csv(tags_csv)
|
|
path = f"api/v1/assets/{urllib.parse.quote(asset_id_text)}"
|
|
data = request_json(
|
|
"PATCH",
|
|
urllib.parse.urljoin(clean_base_url + "/", path),
|
|
clean_workspace_token,
|
|
{"tags": tags, "notes_md": notes_md},
|
|
)
|
|
asset = (data.get("asset") or {}) if isinstance(data.get("asset"), dict) else {}
|
|
compact = compact_asset_row(asset)
|
|
compact["notes_md"] = str(asset.get("notes_md") or "")
|
|
status = {
|
|
"ok": True,
|
|
"asset_id": asset_id_text,
|
|
"updated_tags_count": len(tags),
|
|
"updated_notes_len": len(notes_md or ""),
|
|
"token_source": token_source,
|
|
"token_source_ref": token_source_ref,
|
|
}
|
|
return (asset_id_text, json.dumps(compact, indent=2), json.dumps(status, indent=2))
|
|
except Exception as exc:
|
|
_log_error("UpsertAssetTagsFields", exc)
|
|
return ("", "{}", json.dumps({"ok": False, "error": str(exc)}, indent=2))
|
|
|
|
|
|
class GenAssetAssetSummary:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"base_url": ("STRING", {"default": DEFAULT_BASE_URL}),
|
|
"token": ("STRING", {"default": TOKEN_FILE_HINT, "multiline": False}),
|
|
"asset_id": ("STRING", {"default": "", "tooltip": "Asset id to summarize."}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("summary", "asset_json", "status_json")
|
|
FUNCTION = "summary"
|
|
CATEGORY = CATEGORY
|
|
OUTPUT_NODE = True
|
|
|
|
def summary(self, base_url: str, token: str, asset_id: str):
|
|
try:
|
|
clean_base_url = require_base_url(base_url)
|
|
clean_workspace_token, _, _ = resolve_workspace_token(token)
|
|
asset_id_text = asset_id.strip()
|
|
if not asset_id_text:
|
|
raise RuntimeError("Paste asset_id.")
|
|
path = f"api/v1/assets/{urllib.parse.quote(asset_id_text)}"
|
|
data = request_json("GET", urllib.parse.urljoin(clean_base_url + "/", path), clean_workspace_token)
|
|
asset = (data.get("asset") or {}) if isinstance(data.get("asset"), dict) else {}
|
|
versions = data.get("versions") if isinstance(data.get("versions"), list) else []
|
|
current_version_id = str(asset.get("current_version_id") or "")
|
|
current = next((v for v in versions if isinstance(v, dict) and str(v.get("id") or "") == current_version_id), None)
|
|
if current is None and versions:
|
|
current = versions[0] if isinstance(versions[0], dict) else None
|
|
current_version_number = int(current.get("version_number") or 0) if isinstance(current, dict) else 0
|
|
compact = compact_asset_row(asset)
|
|
compact["version_count"] = len([v for v in versions if isinstance(v, dict)])
|
|
compact["current_version_number"] = current_version_number
|
|
summary = (
|
|
f"asset={compact.get('name') or asset_id_text} | versions={compact['version_count']} | "
|
|
f"current=v{current_version_number} ({current_version_id or 'n/a'})"
|
|
)
|
|
status = {"ok": True, "asset_id": asset_id_text, "version_count": compact["version_count"], "current_version_id": current_version_id}
|
|
return (summary, json.dumps(compact, indent=2), json.dumps(status, indent=2))
|
|
except Exception as exc:
|
|
_log_error("AssetSummaryInGenAsset", exc)
|
|
return ("", "{}", json.dumps({"ok": False, "error": str(exc)}, indent=2))
|