Add custom JSON palette input with live preview and tests

This commit is contained in:
TJ16th
2025-11-10 02:30:37 +09:00
parent 8996f24649
commit 8d08dc768c
4 changed files with 159 additions and 10 deletions
+20
View File
@@ -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"]
+41 -9
View File
@@ -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)
+80
View File
@@ -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;
};
+18 -1
View File
@@ -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")