Add custom JSON palette input with live preview and tests
This commit is contained in:
+20
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user