Improve random and mashup moodboard UX

Balance random selection across style families so photo-like boards do not dominate, and make mashups show the resolved source moodboards while accepting metadata_json, titles, search phrases, UUIDs, slugs, or URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andro-Meta
2026-06-30 12:16:34 -05:00
co-authored by Cursor
parent c57058f511
commit 710a2cec63
11 changed files with 404 additions and 18 deletions
+28 -1
View File
@@ -69,12 +69,37 @@ Use `style_only` if you want to feed the moodboard text into another prompt enha
### Krea Moodboard Random
Picks a deterministic random style by seed. Add a query to randomize within a style family, for example `query = cinematic noir` and `random_from_top_k = 25`.
Picks a deterministic random style by seed. By default it uses `random_mode = balanced`, which samples a style family first so the largest family in the catalog, especially photo-like boards, does not dominate random results.
Random modes:
- `balanced`: recommended default; samples across style families first.
- `any`: pure random from the whole matching pool.
- `non_photo`: avoids boards classified as photo-family when possible.
- `photo`: only photo-family boards when possible.
Add a query to randomize within a style family, for example `query = cinematic noir` and `random_from_top_k = 25`.
### Krea Moodboard Mashup
Blends two to four moodboards by concatenating transferable style guidance, deduping style axes and negative guidance.
What to put in each `board` box:
- Best option: paste or connect the `metadata_json` output from `Krea Moodboard Search`, `Krea Moodboard Random`, or `Krea Moodboard Style`.
- Also works: a moodboard title, search phrase, UUID, slug, or Krea moodboard URL.
Examples:
```text
Abyssal Gothic
warm pastel product
https://www.krea.ai/moodboard-feed/...
{"uuid": "f8ba7b69-987b-5cc3-abd8-fc734a82223a"}
```
The `preview` output lists which source moodboards were resolved. If the mashup looks wrong, check `preview` first and make the board boxes more specific.
### Krea Moodboard Style
Looks up one moodboard by exact title, slug, UUID, URL, or search phrase. This avoids a 2500-item dropdown while keeping every option findable.
@@ -92,6 +117,8 @@ Looks up one moodboard by exact title, slug, UUID, URL, or search phrase. This a
7. Connect `Krea Moodboard Apply.positive` to the Krea-2 Turbo subgraph `Text String (User Prompt)` input.
8. If your workflow exposes a negative prompt or CFG path, connect `Krea Moodboard Apply.negative` there. If it does not, leave it unused.
For mashups, use two or more `Krea Moodboard Search` or `Krea Moodboard Random` nodes, then connect their `metadata_json` outputs into `Krea Moodboard Mashup.board_1`, `board_2`, etc. Connect the Mashup `positive` and `negative` outputs into `Krea Moodboard Apply`.
### Manual Krea 2 Graph
Use these nodes before Krea text encoding:
+1 -1
View File
@@ -18,7 +18,7 @@
{ "name": "metadata_json", "type": "STRING", "links": [3] },
{ "name": "preview", "type": "STRING", "links": null }
],
"widgets_values": ["dark teal gothic", 5, 1, 0, 0, "normal"]
"widgets_values": ["dark teal gothic", 5, 1, 0, 0, "normal", "balanced"]
},
{
"id": 2,
+1 -1
View File
@@ -17,7 +17,7 @@
{ "name": "title", "type": "STRING", "links": null },
{ "name": "metadata_json", "type": "STRING", "links": [3] }
],
"widgets_values": [1234, "cinematic noir", 25, "normal"]
"widgets_values": [1234, "cinematic noir", 25, "normal", "balanced"]
},
{
"id": 2,
@@ -0,0 +1,107 @@
{
"last_node_id": 5,
"last_link_id": 7,
"nodes": [
{
"id": 1,
"type": "KreaMoodboardSearch",
"pos": [120, 120],
"size": [360, 220],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "positive", "type": "STRING", "links": null },
{ "name": "negative", "type": "STRING", "links": null },
{ "name": "title", "type": "STRING", "links": null },
{ "name": "metadata_json", "type": "STRING", "links": [1] },
{ "name": "preview", "type": "STRING", "links": null }
],
"widgets_values": ["dark teal gothic", 5, 1, 0, 0, "normal", "balanced"]
},
{
"id": 2,
"type": "KreaMoodboardSearch",
"pos": [120, 420],
"size": [360, 220],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "positive", "type": "STRING", "links": null },
{ "name": "negative", "type": "STRING", "links": null },
{ "name": "title", "type": "STRING", "links": null },
{ "name": "metadata_json", "type": "STRING", "links": [2] },
{ "name": "preview", "type": "STRING", "links": null }
],
"widgets_values": ["warm pastel product", 5, 1, 0, 0, "normal", "balanced"]
},
{
"id": 3,
"type": "KreaMoodboardMashup",
"pos": [560, 180],
"size": [420, 300],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "board_1", "type": "STRING", "link": 1 },
{ "name": "board_2", "type": "STRING", "link": 2 }
],
"outputs": [
{ "name": "positive", "type": "STRING", "links": [3] },
{ "name": "negative", "type": "STRING", "links": [4] },
{ "name": "title", "type": "STRING", "links": null },
{ "name": "metadata_json", "type": "STRING", "links": [5] },
{ "name": "preview", "type": "STRING", "links": null }
],
"widgets_values": ["", "", 0.65, 0.35, "normal", "", "", 1.0, 1.0]
},
{
"id": 4,
"type": "KreaMoodboardApply",
"pos": [1040, 180],
"size": [420, 280],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{ "name": "prompt", "type": "STRING", "link": null },
{ "name": "negative_prompt", "type": "STRING", "link": null },
{ "name": "moodboard_positive", "type": "STRING", "link": 3 },
{ "name": "moodboard_negative", "type": "STRING", "link": 4 },
{ "name": "metadata_json", "type": "STRING", "link": 5 }
],
"outputs": [
{ "name": "positive", "type": "STRING", "links": null },
{ "name": "negative", "type": "STRING", "links": null },
{ "name": "metadata_json", "type": "STRING", "links": null }
],
"widgets_values": [
"a ceramic fox on a table, editorial product photo",
"",
"",
"",
"{}",
"normal",
false,
"newline"
]
}
],
"links": [
[1, 1, 3, 3, 0, "STRING"],
[2, 2, 3, 3, 1, "STRING"],
[3, 3, 0, 4, 2, "STRING"],
[4, 3, 1, 4, 3, "STRING"],
[5, 3, 3, 4, 4, "STRING"]
],
"groups": [],
"config": {},
"extra": {
"info": "Example mashup wiring: Search metadata_json outputs feed Mashup board inputs, then Mashup positive/negative feed Apply."
},
"version": 0.4
}
+66 -2
View File
@@ -36,6 +36,16 @@ FIELD_WEIGHTS: tuple[tuple[str, int], ...] = (
("negative_guidance", 3),
)
STYLE_FAMILY_TERMS: tuple[tuple[str, tuple[str, ...]], ...] = (
("anime", ("anime", "manga", "kawaii", "chibi", "retroanime")),
("illustration", ("illustration", "illustrated", "ink", "watercolor", "drawing", "sketch", "storybook")),
("graphic", ("graphic", "poster", "typography", "halftone", "pop art", "comic", "vector")),
("abstract", ("abstract", "abstraction", "geometric", "surreal", "spectral", "iridescent")),
("3d", ("3d", "voxel", "isometric", "clay", "render", "cgi", "plastic")),
("photo", ("photo", "photograph", "photographic", "photoreal", "documentary", "camera", "lens")),
("cinematic", ("cinematic", "film", "35mm", "noir", "editorial")),
)
def load_catalog(path: str | Path = CATALOG_PATH) -> list[dict[str, Any]]:
catalog_path = Path(path)
@@ -113,6 +123,7 @@ def random_board(
query: str = "",
random_from_top_k: int = 0,
min_score: int = 1,
random_mode: str = "balanced",
) -> dict[str, Any]:
pool = catalog
if query.strip():
@@ -121,7 +132,21 @@ def random_board(
pool = [match["board"] for match in matches]
if not pool:
raise ValueError("No Krea moodboards matched the query.")
return random.Random(int(seed)).choice(pool)
rng = random.Random(int(seed))
if random_mode == "any":
return rng.choice(pool)
if random_mode == "photo":
filtered = [board for board in pool if style_family(board) == "photo"]
return rng.choice(filtered or pool)
if random_mode == "non_photo":
filtered = [board for board in pool if style_family(board) != "photo"]
return rng.choice(filtered or pool)
grouped: dict[str, list[dict[str, Any]]] = {}
for board in pool:
grouped.setdefault(style_family(board), []).append(board)
family = rng.choice(sorted(grouped))
return rng.choice(grouped[family])
def find_board(catalog: list[dict[str, Any]], value: str) -> dict[str, Any]:
@@ -143,6 +168,27 @@ def find_board(catalog: list[dict[str, Any]], value: str) -> dict[str, Any]:
raise ValueError(f"No Krea moodboard matched: {value}")
def resolve_board_reference(catalog: list[dict[str, Any]], value: str) -> dict[str, Any]:
"""Resolve title/search text, slug, UUID, URL, or metadata_json into a board."""
raw = str(value or "").strip()
if not raw:
raise ValueError("Provide a moodboard title, search text, URL, UUID, slug, or metadata_json.")
try:
data = json.loads(raw)
except json.JSONDecodeError:
return find_board(catalog, raw)
if not isinstance(data, dict):
return find_board(catalog, raw)
for key in ("uuid", "url", "slug", "title"):
candidate = str(data.get(key) or "").strip()
if candidate:
try:
return find_board(catalog, candidate)
except ValueError:
continue
return find_board(catalog, raw)
def style_from_board(board: dict[str, Any], *, strength: str = "normal") -> dict[str, str]:
guidance = _guidance(board)
style_axes = _string_list(guidance.get("style_axes"))
@@ -246,15 +292,33 @@ def mashup_boards(
style_axes.append(axis)
sources.append({"title": title, "url": metadata.get("url", ""), "uuid": metadata.get("uuid", "")})
source_titles = [source["title"] for source in sources]
mashup_title = "Mashup: " + " + ".join(source_titles[:4])
metadata = {"source_count": len(sources), "sources": sources, "style_axes": style_axes}
preview = "\n".join(
f"{idx}. {source['title']} | {source['url']}" for idx, source in enumerate(sources, start=1)
)
return {
"positive": "Blend these Krea moodboard styles: " + " | ".join(positives),
"negative": " ".join(_dedupe(negatives)),
"title": "Krea Moodboard Mashup",
"title": mashup_title,
"metadata_json": json.dumps(metadata, ensure_ascii=False, sort_keys=True),
"preview": (
"Mashup sources resolved. Paste metadata_json from Search/Random/Style nodes, "
"or type a moodboard title, search phrase, UUID, slug, or Krea URL.\n"
f"{preview}"
),
}
def style_family(board: dict[str, Any]) -> str:
text = " ".join(_search_fields(board).values()).lower()
for family, terms in STYLE_FAMILY_TERMS:
if any(term in text for term in terms):
return family
return "other"
def _score_board(board: dict[str, Any], expanded_terms: list[str]) -> tuple[int, list[str]]:
score = 0
matched: list[str] = []
+78 -11
View File
@@ -10,6 +10,7 @@ from moodboard_catalog import (
load_catalog,
mashup_boards,
random_board,
resolve_board_reference,
search_boards,
style_from_board,
)
@@ -17,6 +18,7 @@ from moodboard_catalog import (
STRENGTHS = ["concise", "normal", "strong"]
SEPARATORS = ["newline", "comma"]
RANDOM_MODES = ["balanced", "any", "non_photo", "photo"]
@lru_cache(maxsize=1)
@@ -110,10 +112,26 @@ class KreaMoodboardSearch:
STRENGTHS,
{"default": "normal", "tooltip": "Controls detail level in the positive style text."},
),
"random_mode": (
RANDOM_MODES,
{
"default": "balanced",
"tooltip": "balanced samples style families first; any samples the whole pool; non_photo avoids photo-family boards.",
},
),
}
}
def search(self, query: str, top_k: int, min_score: int, random_from_top_k: int, seed: int, strength: str):
def search(
self,
query: str,
top_k: int,
min_score: int,
random_from_top_k: int,
seed: int,
strength: str,
random_mode: str = "balanced",
):
catalog = _catalog()
matches = search_boards(catalog, query, top_k=top_k, min_score=min_score)
if not matches:
@@ -125,6 +143,7 @@ class KreaMoodboardSearch:
query=query,
random_from_top_k=min(random_from_top_k, top_k),
min_score=min_score,
random_mode=random_mode,
)
else:
board = matches[0]["board"]
@@ -158,19 +177,39 @@ class KreaMoodboardRandom:
{"default": 25, "min": 1, "max": 100, "step": 1, "tooltip": "Random pool size after search."},
),
"strength": (STRENGTHS, {"default": "normal"}),
"random_mode": (
RANDOM_MODES,
{
"default": "balanced",
"tooltip": "balanced samples style families first so photo boards do not dominate random picks.",
},
),
}
}
def random_style(self, seed: int, query: str, random_from_top_k: int, strength: str):
board = random_board(_catalog(), seed=seed, query=query, random_from_top_k=random_from_top_k)
def random_style(
self,
seed: int,
query: str,
random_from_top_k: int,
strength: str,
random_mode: str = "balanced",
):
board = random_board(
_catalog(),
seed=seed,
query=query,
random_from_top_k=random_from_top_k,
random_mode=random_mode,
)
style = style_from_board(board, strength=strength)
return style["positive"], style["negative"], style["title"], style["metadata_json"]
class KreaMoodboardMashup:
CATEGORY = "Krea/Moodboards"
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("positive", "negative", "title", "metadata_json")
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING", "STRING")
RETURN_NAMES = ("positive", "negative", "title", "metadata_json", "preview")
FUNCTION = "mashup"
DESCRIPTION = "Blend two to four Krea moodboards into one prompt style."
@@ -178,15 +217,43 @@ class KreaMoodboardMashup:
def INPUT_TYPES(cls):
return {
"required": {
"board_1": ("STRING", {"default": "", "multiline": False}),
"board_2": ("STRING", {"default": "", "multiline": False}),
"board_1": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Paste metadata_json from another Krea Moodboard node, or type a title, search phrase, UUID, slug, or URL.",
},
),
"board_2": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Paste metadata_json from another Krea Moodboard node, or type a title, search phrase, UUID, slug, or URL.",
},
),
"weight_1": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
"weight_2": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
"strength": (STRENGTHS, {"default": "normal"}),
},
"optional": {
"board_3": ("STRING", {"default": "", "multiline": False}),
"board_4": ("STRING", {"default": "", "multiline": False}),
"board_3": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional third board: metadata_json, title, search phrase, UUID, slug, or URL.",
},
),
"board_4": (
"STRING",
{
"default": "",
"multiline": True,
"tooltip": "Optional fourth board: metadata_json, title, search phrase, UUID, slug, or URL.",
},
),
"weight_3": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
"weight_4": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.05}),
},
@@ -207,9 +274,9 @@ class KreaMoodboardMashup:
catalog = _catalog()
names = [board_1, board_2, board_3, board_4]
weights = [weight_1, weight_2, weight_3, weight_4]
boards = [find_board(catalog, name) for name in names if str(name or "").strip()]
boards = [resolve_board_reference(catalog, name) for name in names if str(name or "").strip()]
style = mashup_boards(boards, weights=weights[: len(boards)], strength=strength)
return style["positive"], style["negative"], style["title"], style["metadata_json"]
return style["positive"], style["negative"], style["title"], style["metadata_json"], style["preview"]
class KreaMoodboardApply:
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "comfyui-krea-moodboards"
version = "0.1.0"
version = "0.1.1"
description = "Prompt-only Krea moodboard search and style nodes for ComfyUI."
readme = "README.md"
requires-python = ">=3.10"
+13
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
import json
from pathlib import Path
from moodboard_catalog import CATALOG_PATH, load_catalog
@@ -10,3 +13,13 @@ def test_bundled_catalog_contains_prompt_only_krea_moodboards() -> None:
assert all(board.get("url", "").startswith("https://www.krea.ai/moodboard-feed/") for board in catalog)
assert all("image_urls" not in board for board in catalog)
assert all(board.get("qwen_guidance", {}).get("prompt_guidance") for board in catalog)
def test_example_workflows_are_valid_json() -> None:
example_paths = sorted(Path("examples").glob("*.json"))
assert example_paths
for path in example_paths:
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload.get("nodes"), path
assert payload.get("version"), path
+56
View File
@@ -11,6 +11,7 @@ from moodboard_catalog import (
load_catalog,
mashup_boards,
random_board,
resolve_board_reference,
search_boards,
style_from_board,
)
@@ -102,6 +103,58 @@ def test_random_board_is_deterministic_and_can_use_top_matches(tmp_path: Path) -
assert first["uuid"] == second["uuid"]
def test_balanced_random_does_not_let_photo_styles_dominate() -> None:
photo_boards = [
{
"url": f"https://www.krea.ai/moodboard-feed/photo-{index}",
"slug": f"photo-{index}",
"uuid": f"photo-{index}",
"title": f"Photo Style {index}",
"taste_profile": "cinematic photo lens documentary film look",
"keywords": ["photo", "cinematic"],
"qwen_guidance": {
"prompt_guidance": "Use photographic lens lighting and cinematic film texture.",
"negative_guidance": "",
"style_axes": ["photographic"],
"conditioning_notes": [],
"source_summary": "Photo style.",
},
}
for index in range(20)
]
illustration_board = {
"url": "https://www.krea.ai/moodboard-feed/illustration",
"slug": "illustration",
"uuid": "illustration",
"title": "Ink Illustration",
"taste_profile": "flat ink drawing and illustrated poster language",
"keywords": ["illustration", "ink"],
"qwen_guidance": {
"prompt_guidance": "Use ink illustration, flat shapes, and graphic poster texture.",
"negative_guidance": "",
"style_axes": ["illustration"],
"conditioning_notes": [],
"source_summary": "Illustration style.",
},
}
catalog = photo_boards + [illustration_board]
picks = [random_board(catalog, seed=seed, random_mode="balanced") for seed in range(100)]
assert sum(board["uuid"] == "illustration" for board in picks) >= 25
def test_resolve_board_reference_accepts_metadata_json_or_search_text(tmp_path: Path) -> None:
catalog = load_catalog(write_catalog(tmp_path / "catalog.json"))
metadata_ref = json.dumps({"uuid": "11111111-1111-5111-9111-111111111111"})
from_metadata = resolve_board_reference(catalog, metadata_ref)
from_search = resolve_board_reference(catalog, "warm product")
assert from_metadata["title"] == "Abyssal Gothic"
assert from_search["title"] == "Warm Product Pastel"
def test_style_from_board_returns_positive_negative_and_metadata(tmp_path: Path) -> None:
catalog = load_catalog(write_catalog(tmp_path / "catalog.json"))
@@ -140,6 +193,9 @@ def test_mashup_dedupes_negative_guidance_and_style_axes(tmp_path: Path) -> None
assert "Abyssal Gothic" in mashup["positive"]
assert "Warm Product Pastel" in mashup["positive"]
assert mashup["title"] == "Mashup: Abyssal Gothic + Warm Product Pastel"
assert "Avoid flat bright daylight" in mashup["negative"]
assert metadata["source_count"] == 2
assert "deep teal" in metadata["style_axes"]
assert "Abyssal Gothic" in mashup["preview"]
assert "Warm Product Pastel" in mashup["preview"]
+52
View File
@@ -70,3 +70,55 @@ def test_apply_node_combines_prompt_and_style_metadata() -> None:
assert "warm pastel lighting" in positive
assert negative == "low quality, Avoid harsh contrast."
assert json.loads(metadata_json)["title"] == "Warm Pastel"
def test_mashup_node_accepts_metadata_json_and_outputs_preview(monkeypatch) -> None:
boards = [
{
"url": "https://www.krea.ai/moodboard-feed/gothic",
"slug": "gothic",
"uuid": "gothic-uuid",
"title": "Gothic Teal",
"taste_profile": "Deep teal gothic style.",
"keywords": ["gothic", "teal"],
"qwen_guidance": {
"prompt_guidance": "Use deep teal gothic lighting.",
"negative_guidance": "Avoid flat daylight.",
"style_axes": ["deep teal"],
"conditioning_notes": [],
"source_summary": "summary",
},
},
{
"url": "https://www.krea.ai/moodboard-feed/pastel",
"slug": "pastel",
"uuid": "pastel-uuid",
"title": "Pastel Product",
"taste_profile": "Warm product studio style.",
"keywords": ["pastel", "product"],
"qwen_guidance": {
"prompt_guidance": "Use warm pastel studio lighting.",
"negative_guidance": "Avoid harsh contrast.",
"style_axes": ["warm pastel"],
"conditioning_notes": [],
"source_summary": "summary",
},
},
]
monkeypatch.setattr(nodes, "_catalog", lambda: boards)
positive, negative, title, metadata_json, preview = nodes.KreaMoodboardMashup().mashup(
board_1=json.dumps({"uuid": "gothic-uuid"}),
board_2="pastel product",
weight_1=0.7,
weight_2=0.3,
strength="normal",
)
assert title == "Mashup: Gothic Teal + Pastel Product"
assert "Gothic Teal" in positive
assert "Pastel Product" in positive
assert "Avoid flat daylight" in negative
assert json.loads(metadata_json)["source_count"] == 2
assert "Gothic Teal" in preview
assert "Paste metadata_json" in preview
+1 -1
View File
@@ -8,7 +8,7 @@ def test_pyproject_has_comfy_registry_metadata() -> None:
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
assert pyproject["project"]["name"] == "comfyui-krea-moodboards"
assert pyproject["project"]["version"] == "0.1.0"
assert pyproject["project"]["version"] == "0.1.1"
assert pyproject["project"]["dependencies"] == []
assert pyproject["tool"]["comfy"]["PublisherId"] == "andrometa"
assert pyproject["tool"]["comfy"]["DisplayName"] == "Krea Moodboards"