From 8d08dc768cfe91c165ee9ce00ee6a2c0d6fb6a8f Mon Sep 17 00:00:00 2001 From: TJ16th Date: Mon, 10 Nov 2025 02:30:37 +0900 Subject: [PATCH] Add custom JSON palette input with live preview and tests --- __init__.py | 20 ++++++++++ color_palette.py | 50 ++++++++++++++++++----- js/color_palette.js | 80 +++++++++++++++++++++++++++++++++++++ tests/test_color_palette.py | 19 ++++++++- 4 files changed, 159 insertions(+), 10 deletions(-) diff --git a/__init__.py b/__init__.py index db82114..349d837 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,7 @@ from .rgb_color_picker import RGBColorPicker from .color_palette import ColorPalette +from aiohttp import web +import server NODE_CLASS_MAPPINGS = { "RGBColorPicker": RGBColorPicker, @@ -14,4 +16,22 @@ NODE_DISPLAY_NAME_MAPPINGS = { # Serve frontend JS from the js/ folder WEB_DIRECTORY = "./js" +@server.PromptServer.instance.routes.post("/tj_comfyuiutil/palette") +async def get_palette_colors(request): + """Return colors for a given preset or custom json without executing the node. + Body: {"preset":"primary"} or {"preset":"custom","custom_json":"{...}"} + """ + try: + data = await request.json() + preset = data.get("preset", "primary") + custom_json = data.get("custom_json", "") + palette_node = ColorPalette() + if preset == "custom": + colors = palette_node._parse_custom(custom_json) + else: + colors = palette_node.load_palette(preset) + return web.json_response(colors) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/color_palette.py b/color_palette.py index f404576..37093ea 100644 --- a/color_palette.py +++ b/color_palette.py @@ -9,12 +9,14 @@ from typing import Tuple class ColorPalette: """ ComfyUI custom node: ColorPalette - - Input: preset (選択式: primary, pastel, gansai, monochrome, またはカスタム) - - Output: color_1 ~ color_16 (各 #RRGGBB) + - Input: + preset: primary / pastel / gansai / monochrome / custom + custom_json: (preset == custom の場合のみ使用) {"colors":["#RRGGBB", ...]} 形式(最大8色) + - Output: color_0 ~ color_7 (各 #RRGGBB) - Category: TJnodes/color - + パレットは palettes/ フォルダの JSON から読み込み。 - ユーザーは独自 JSON を追加可能。 + custom 選択時は custom_json の内容を直接使用。 """ def __init__(self): @@ -23,17 +25,20 @@ class ColorPalette: @classmethod def INPUT_TYPES(cls): - # palettes/ から利用可能なプリセットを列挙 + # palettes/ から利用可能なプリセットを列挙 + custom instance = cls() presets = instance.list_presets() + if "custom" not in presets: + presets.append("custom") return { "required": { "preset": (presets, {"default": presets[0] if presets else "primary"}), + "custom_json": ("STRING", {"multiline": True, "default": '{"colors":["#FF0000","#00FF00","#0000FF","#FFFF00","#FF00FF","#00FFFF","#FFFFFF","#000000"]}'}) } } RETURN_TYPES = tuple(["STRING"] * 8) - RETURN_NAMES = tuple([f"color_{i+1}" for i in range(8)]) + RETURN_NAMES = tuple([f"color_{i}" for i in range(8)]) FUNCTION = "get_palette" CATEGORY = "TJnodes/color" @@ -74,7 +79,34 @@ class ColorPalette: # エラー時フォールバック return [f"#{i*32:02X}{i*32:02X}{i*32:02X}" for i in range(8)] - def get_palette(self, preset: str) -> Tuple[str, ...]: - """プリセットから16色を取得して返す""" - colors = self.load_palette(preset) + def _parse_custom(self, custom_json: str) -> list[str]: + """custom_json 文字列を解析して #RRGGBB リスト(最大8)を返す。失敗時はグレー階調。""" + try: + data = json.loads(custom_json) + raw = data.get("colors", []) + colors: list[str] = [] + for c in raw: + if not isinstance(c, str): + continue + c = c.strip() + if len(c) == 7 and c.startswith("#"): + # 16進検証 + h = c[1:] + if all(ch in "0123456789abcdefABCDEF" for ch in h): + colors.append("#" + h.upper()) + if len(colors) >= 8: + break + while len(colors) < 8: + colors.append("#000000") + return colors[:8] + except Exception as e: + print(f"[ColorPalette] custom_json parse error: {e}") + return [f"#{i*32:02X}{i*32:02X}{i*32:02X}" for i in range(8)] + + def get_palette(self, preset: str, custom_json: str = "") -> Tuple[str, ...]: + """プリセットから8色を取得して返す。preset==custom の場合 custom_json を解析。""" + if preset == "custom": + colors = self._parse_custom(custom_json) + else: + colors = self.load_palette(preset) return tuple(colors) diff --git a/js/color_palette.js b/js/color_palette.js index 882bf47..4b300aa 100644 --- a/js/color_palette.js +++ b/js/color_palette.js @@ -74,6 +74,35 @@ function updateChips(node, chips) { } } +function parseCustomColors(text) { + try { + if (!text || !text.trim()) return null; + const data = JSON.parse(text); + const arr = Array.isArray(data) ? data : data.colors; + if (!Array.isArray(arr)) return null; + const out = []; + for (const c of arr) { + if (typeof c !== 'string') continue; + const v = c.trim().toUpperCase(); + if (/^#[0-9A-F]{6}$/.test(v)) { + out.push(v); + if (out.length >= 8) break; + } + } + if (!out.length) return null; + while (out.length < 8) out.push('#000000'); + return out.slice(0, 8); + } catch { + return null; + } +} + +function setErrorVisual(node, on) { + const ui = node.__palette_ui__; + if (!ui) return; + ui.container.style.outline = on ? '2px solid #f55' : 'none'; +} + function attachPaletteUI(node) { if (node.__palette_ui__) return node.__palette_ui__; @@ -102,6 +131,57 @@ app.registerExtension({ nodeType.prototype.onNodeCreated = function() { const r = onNodeCreated?.apply(this, arguments); attachPaletteUI(this); + + // widget 参照 + const presetWidget = this.widgets?.find(w => w.name === 'preset'); + const customWidget = this.widgets?.find(w => w.name === 'custom_json'); + + const refreshFromWidgets = async () => { + const preset = presetWidget?.value; + if (!preset) return; + if (preset === 'custom') { + const colors = parseCustomColors(customWidget?.value || ''); + if (colors) { + this.__palette_colors__ = colors; + setErrorVisual(this, false); + updateChips(this, this.__palette_ui__.chips); + } else { + this.__palette_colors__ = null; + setErrorVisual(this, !!(customWidget?.value && customWidget.value.trim())) + updateChips(this, this.__palette_ui__.chips); + } + } else { + // プリセットはサーバーから即時取得 + try { + const response = await fetch('/tj_comfyuiutil/palette', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preset }) + }); + if (response.ok) { + const colors = await response.json(); + this.__palette_colors__ = colors; + setErrorVisual(this, false); + updateChips(this, this.__palette_ui__.chips); + } + } catch (e) { + console.error(`[${EXT_NAME}] Failed to fetch palette:`, e); + } + } + }; + + // コールバックを装着 + if (presetWidget) { + const orig = presetWidget.callback; + presetWidget.callback = (v) => { refreshFromWidgets(); orig?.call(presetWidget, v); }; + } + if (customWidget) { + const orig = customWidget.callback; + customWidget.callback = (v) => { refreshFromWidgets(); orig?.call(customWidget, v); }; + } + // 初期 + refreshFromWidgets(); + return r; }; diff --git a/tests/test_color_palette.py b/tests/test_color_palette.py index 6b66715..dcb3329 100644 --- a/tests/test_color_palette.py +++ b/tests/test_color_palette.py @@ -34,12 +34,29 @@ class TestColorPalette(unittest.TestCase): self.assertEqual(colors[7], "#000000") # black end def test_get_palette_output(self): - """get_palette が8要素のタプルを返す""" + """get_palette が8要素のタプルを返す (既存プリセット)""" result = self.node.get_palette("primary") self.assertIsInstance(result, tuple) self.assertEqual(len(result), 8) self.assertTrue(all(isinstance(c, str) for c in result)) + def test_get_palette_custom_inline(self): + """custom_json 文字列経由でカスタム8色が取得できる""" + custom_json = '{"colors":["#112233","#445566","#778899","#AABBCC","#DDEEFF","#123456","#654321","#FEDCBA"]}' + result = self.node.get_palette("custom", custom_json) + self.assertEqual(len(result), 8) + self.assertEqual(result[0], "#112233") + self.assertEqual(result[-1], "#FEDCBA") + + def test_get_palette_custom_short(self): + """8色未満は #000000 でパディング""" + custom_json = '{"colors":["#000000","#FFFFFF"]}' + result = self.node.get_palette("custom", custom_json) + self.assertEqual(len(result), 8) + self.assertEqual(result[0], "#000000") + self.assertEqual(result[1], "#FFFFFF") + self.assertEqual(result[2], "#000000") # padded + def test_nonexistent_preset_fallback(self): """存在しないプリセットはフォールバックで8色を返す""" colors = self.node.load_palette("nonexistent")