diff --git a/README.md b/README.md index 7356d06..b3d9e6d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,10 @@ ## リポジトリ - GitHub: https://github.com/TJ16th/TJ_ComfyUI_ColorUtility -## 特長 +## ノード一覧 + +### RGB Color Picker +高精度な RGB/HSV/Alpha カラーピッカー - 色相環 + 中央 SV 正方形 (ピクセル単位 HSV→RGB 変換により端の純白/純黒を保証) - アルファバー + RGBA プレビュー (チェック柄背景) - #RRGGBB と #RRGGBBAA の 2 出力 (互換性維持のため従来 hex_color も提供) @@ -13,6 +16,14 @@ - HiDPI / スケール対応 (devicePixelRatio 利用) - 端スナップ (左上=白 / 右下=黒) により色の再現性を確保 +### Color Palette +8色のプリセットカラーパレット +- 入力: `preset` (primary, pastel, gansai, monochrome など) +- 出力: 8個の #RRGGBB 文字列 (`color_1` ~ `color_8`) +- ノード下部に8色のカラーチップを表示 +- プリセット変更で即座にチップ更新 +- カスタムパレット対応 (`palettes/` に JSON 配置) + ## インストール 1. ComfyUI の `custom_nodes` ディレクトリへ本リポジトリを配置 (もしくは git clone)。 ```powershell @@ -44,10 +55,54 @@ git pull --ff-only > メモ: 上記画像は `docs/overview.png` を参照しています。お手元の環境で画像を差し替える場合は、`docs/overview.png` にスクリーンショットを配置してください。 +## カスタムパレットの作成 +`palettes/` フォルダに新しい JSON ファイルを配置することで、独自のカラーパレットを追加できます。 + +**フォーマット** +```json +{ + "name": "My Custom Palette", + "colors": [ + "#FF0000", + "#00FF00", + "#0000FF", + "#FFFF00", + "#FF00FF", + "#00FFFF", + "#FFFFFF", + "#000000" + ] +} +``` + +- `colors` 配列は **8色** を含める必要があります。 +- 8色未満の場合は `#000000` で埋められます。 +- ファイル名 (拡張子を除く) がプリセット名として表示されます。 + +**例: `palettes/sunset.json`** +```json +{ + "name": "Sunset Gradient", + "colors": [ + "#FFD700", + "#FFB347", + "#FF8C00", + "#FF4500", + "#DC143C", + "#8B0000", + "#4B0000", + "#000000" + ] +} +``` + +ComfyUI を再起動すると、ColorPalette ノードの `preset` ドロップダウンに反映されます。 + ## アップデート計画 (例) - キーボード操作対応 - 可変リング太さ/サイズ設定 - HSV / HSL 数値ウィジェット追加 +- カラーパレットのグラデーション生成機能 ## ライセンス 本リポジトリは MIT License です。詳細は `LICENSE` を参照してください。 diff --git a/__init__.py b/__init__.py index ab148e7..db82114 100644 --- a/__init__.py +++ b/__init__.py @@ -1,11 +1,14 @@ from .rgb_color_picker import RGBColorPicker +from .color_palette import ColorPalette NODE_CLASS_MAPPINGS = { "RGBColorPicker": RGBColorPicker, + "ColorPalette": ColorPalette, } NODE_DISPLAY_NAME_MAPPINGS = { "RGBColorPicker": "RGB Color Picker", + "ColorPalette": "Color Palette", } # Serve frontend JS from the js/ folder diff --git a/color_palette.py b/color_palette.py new file mode 100644 index 0000000..f404576 --- /dev/null +++ b/color_palette.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Tuple + + +class ColorPalette: + """ + ComfyUI custom node: ColorPalette + - Input: preset (選択式: primary, pastel, gansai, monochrome, またはカスタム) + - Output: color_1 ~ color_16 (各 #RRGGBB) + - Category: TJnodes/color + + パレットは palettes/ フォルダの JSON から読み込み。 + ユーザーは独自 JSON を追加可能。 + """ + + def __init__(self): + self.palettes_dir = Path(__file__).parent / "palettes" + self._palette_cache = {} + + @classmethod + def INPUT_TYPES(cls): + # palettes/ から利用可能なプリセットを列挙 + instance = cls() + presets = instance.list_presets() + return { + "required": { + "preset": (presets, {"default": presets[0] if presets else "primary"}), + } + } + + RETURN_TYPES = tuple(["STRING"] * 8) + RETURN_NAMES = tuple([f"color_{i+1}" for i in range(8)]) + FUNCTION = "get_palette" + CATEGORY = "TJnodes/color" + + def list_presets(self) -> list[str]: + """palettes/ 内の .json ファイルから利用可能なプリセット名を返す""" + if not self.palettes_dir.exists(): + return ["primary"] + + presets = [] + for file in self.palettes_dir.glob("*.json"): + presets.append(file.stem) + return sorted(presets) if presets else ["primary"] + + def load_palette(self, preset_name: str) -> list[str]: + """指定プリセットの色リストを返す。キャッシュ利用。""" + if preset_name in self._palette_cache: + return self._palette_cache[preset_name] + + palette_file = self.palettes_dir / f"{preset_name}.json" + if not palette_file.exists(): + # フォールバック: 基本8色 (モノクログラデーション) + fallback = [f"#{i*32:02X}{i*32:02X}{i*32:02X}" for i in range(8)] + return fallback + + try: + with open(palette_file, "r", encoding="utf-8") as f: + data = json.load(f) + colors = data.get("colors", []) + # 8色に満たない場合は #000000 で埋める + while len(colors) < 8: + colors.append("#000000") + # 8色超過は切り捨て + colors = colors[:8] + self._palette_cache[preset_name] = colors + return colors + except Exception as e: + print(f"[ColorPalette] Error loading {preset_name}: {e}") + # エラー時フォールバック + 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) + return tuple(colors) diff --git a/js/color_palette.js b/js/color_palette.js new file mode 100644 index 0000000..882bf47 --- /dev/null +++ b/js/color_palette.js @@ -0,0 +1,149 @@ +// ColorPalette extension for ComfyUI +// Displays 16 color chips below the node based on selected preset +import { app } from "/scripts/app.js"; + +const EXT_NAME = "Comfy.TJnodes.ColorPalette"; +console.log(`[${EXT_NAME}] loading`); + +function createChipsContainer() { + const container = document.createElement("div"); + container.style.position = "fixed"; + container.style.zIndex = "100"; + container.style.display = "grid"; + container.style.gridTemplateColumns = "repeat(8, 24px)"; + container.style.gap = "4px"; + container.style.pointerEvents = "none"; + container.style.transformOrigin = "top left"; + + // 8 chips + const chips = []; + for (let i = 0; i < 8; i++) { + const chip = document.createElement("div"); + chip.style.width = "24px"; + chip.style.height = "24px"; + chip.style.borderRadius = "4px"; + chip.style.border = "1px solid #fff"; + chip.style.background = "#000"; + container.appendChild(chip); + chips.push(chip); + } + + const canvas = app.canvas?.canvas; + const canvasContainer = canvas?.parentElement || document.body; + canvasContainer.appendChild(container); + + return { container, chips }; +} + +function positionChips(node, container) { + const ds = app.canvas?.ds; + const canvas = app.canvas?.canvas; + if (!ds || !canvas) return; + + const rect = canvas.getBoundingClientRect(); + const scale = ds.scale || 1; + const [offX, offY] = ds.offset || [0, 0]; + + const nodeX = (node.pos[0] + offX) * scale + rect.left; + const nodeY = (node.pos[1] + offY) * scale + rect.top; + const gap = 8; + + const x = nodeX; + const y = nodeY + node.size[1] * scale + gap; + + container.style.left = `${x}px`; + container.style.top = `${y}px`; + container.style.transform = `scale(${scale})`; +} + +function updateChips(node, chips) { + // node.widgets から各 color_N の値を取得して chip に反映 + // ColorPalette ノードは preset widget のみなので、出力値を widgets に保持する必要あり + // → onExecuted で outputs から色を取得 + if (!node.__palette_colors__) { + // デフォルト: グレー + for (let i = 0; i < 8; i++) { + chips[i].style.background = `#${(i * 32).toString(16).padStart(2, '0').repeat(3)}`; + } + return; + } + + const colors = node.__palette_colors__; + for (let i = 0; i < 8 && i < colors.length; i++) { + chips[i].style.background = colors[i]; + } +} + +function attachPaletteUI(node) { + if (node.__palette_ui__) return node.__palette_ui__; + + const { container, chips } = createChipsContainer(); + updateChips(node, chips); + + node.__palette_ui__ = { container, chips }; + return node.__palette_ui__; +} + +app.registerExtension({ + name: EXT_NAME, + async beforeRegisterNodeDef(nodeType, nodeData, _app) { + if (nodeData.name !== "ColorPalette") return; + + const onDrawForeground = nodeType.prototype.onDrawForeground; + nodeType.prototype.onDrawForeground = function(ctx) { + const r = onDrawForeground?.apply(this, arguments); + const ui = attachPaletteUI(this); + positionChips(this, ui.container); + updateChips(this, ui.chips); + return r; + }; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + const r = onNodeCreated?.apply(this, arguments); + attachPaletteUI(this); + return r; + }; + + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function(message) { + const r = onExecuted?.apply(this, arguments); + // outputs から8色を取得してキャッシュ + if (message && Array.isArray(message)) { + // message は [color_1, color_2, ..., color_8] 形式 + this.__palette_colors__ = message.slice(0, 8); + if (this.__palette_ui__) { + updateChips(this, this.__palette_ui__.chips); + } + } + return r; + }; + + // プリセット widget 変更時にも色をリロード (即座に反映) + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function() { + const r = onConfigure?.apply(this, arguments); + // preset widget から現在値を取得してサーバーに問い合わせずローカル再描画 + // (実際はサーバー実行後に onExecuted で更新されるが、ここでは初期表示をクリア) + const presetWidget = this.widgets?.find(w => w.name === "preset"); + if (presetWidget && presetWidget.value) { + // プリセット変更時は一旦リセット (実行後に正しい色が反映される) + this.__palette_colors__ = null; + if (this.__palette_ui__) { + updateChips(this, this.__palette_ui__.chips); + } + } + return r; + }; + + const onRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function() { + const r = onRemoved?.apply(this, arguments); + if (this.__palette_ui__) { + try { this.__palette_ui__.container.remove(); } catch {} + this.__palette_ui__ = null; + } + return r; + }; + } +}); diff --git a/js/rgb_color_picker.js b/js/rgb_color_picker.js index 3653dc4..cc1a7c8 100644 --- a/js/rgb_color_picker.js +++ b/js/rgb_color_picker.js @@ -134,7 +134,7 @@ function drawHueRingAndSV(canvas, hueDeg, s, v, markers) { ctx.closePath(); ctx.fill('evenodd'); } else { - // Fallback: coarse pixel method + // Fallback: coarse pixel method (also align with -90° offset) const image = ctx.createImageData(w, h); for (let yy = 0; yy < h; yy++) { for (let xx = 0; xx < w; xx++) { @@ -143,7 +143,8 @@ function drawHueRingAndSV(canvas, hueDeg, s, v, markers) { const idx = (yy*w + xx) * 4; if (dist <= outer && dist >= inner) { let ang = Math.atan2(dy, dx); - let hdeg = ang * 180 / Math.PI; if (hdeg < 0) hdeg += 360; + let hdeg = ang * 180 / Math.PI - 90; // align with conic gradient start + if (hdeg < 0) hdeg += 360; const [rr, gg, bb] = hsvToRgb(hdeg, 1, 1); image.data[idx] = rr; image.data[idx+1] = gg; image.data[idx+2] = bb; image.data[idx+3] = 255; } else { @@ -194,9 +195,11 @@ function drawHueRingAndSV(canvas, hueDeg, s, v, markers) { // Markers const H = markers?.h ?? hueDeg; const S = markers?.s ?? s; const V = markers?.v ?? v; // hue marker in ring centerline + // H is already the canvas angle (from atan2 + 90), so subtract 90 to get back to standard angle const hr = inner + ringThickness / 2; - const hx = cx + hr * Math.cos(H * Math.PI / 180); - const hy = cy + hr * Math.sin(H * Math.PI / 180); + const hAngleRad = (H - 90) * Math.PI / 180; + const hx = cx + hr * Math.cos(hAngleRad); + const hy = cy + hr * Math.sin(hAngleRad); ctx.save(); ctx.strokeStyle = "#fff"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(hx, hy, 6, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); // sv marker const svX = topLeftX + S * side; @@ -307,7 +310,11 @@ function attachPicker(node) { const dist = Math.sqrt(dx * dx + dy * dy); // Hue ring region if (dist <= geometry.outer && dist >= geometry.inner) { - let ang = Math.atan2(dy, dx) * 180 / Math.PI; if (ang < 0) ang += 360; + // atan2 gives angle from right (0°), conic gradient starts at top (-90°) + // Canvas coord: right=0°, down=90°, left=180°, up=270° + // Color wheel: up=0° (red), so add 90° to convert canvas angle to hue + let ang = Math.atan2(dy, dx) * 180 / Math.PI + 90; + if (ang < 0) ang += 360; h = ang; updatePreview(); return true; @@ -317,22 +324,27 @@ function attachPicker(node) { x >= geometry.topLeftX && x <= geometry.topLeftX + geometry.side && y >= geometry.topLeftY && y <= geometry.topLeftY + geometry.side ) { + // relX, relY: 0..1 の相対座標 const relX = (x - geometry.topLeftX) / geometry.side; const relY = (y - geometry.topLeftY) / geometry.side; - // ピクセル基準の端スナップ(拡大縮小やHiDPIでも安定) - const snapPx = Math.max(2, geometry.side * 0.01); // 最小2px or 1% + + // ピクセル基準の端スナップ(scale正規化済み座標系で判定) + const snapPx = Math.max(2 / scale, geometry.side * 0.01); // 最小2px(画面座標)or 1% const leftDist = x - geometry.topLeftX; const rightDist = (geometry.topLeftX + geometry.side) - x; const topDist = y - geometry.topLeftY; const bottomDist = (geometry.topLeftY + geometry.side) - y; - // s (0..1) + + // s (0..1): 左端=0, 右端=1 if (leftDist <= snapPx) s = 0; else if (rightDist <= snapPx) s = 1; else s = clamp(relX, 0, 1); - // v (0..1) 上=1, 下=0 + + // v (0..1): 上=1, 下=0 if (topDist <= snapPx) v = 1; else if (bottomDist <= snapPx) v = 0; else v = 1 - clamp(relY, 0, 1); + updatePreview(); return true; } diff --git a/palettes/gansai.json b/palettes/gansai.json new file mode 100644 index 0000000..eeab916 --- /dev/null +++ b/palettes/gansai.json @@ -0,0 +1,13 @@ +{ + "name": "Gansai (Japanese Watercolor)", + "colors": [ + "#D32F2F", + "#F57C00", + "#FBC02D", + "#689F38", + "#00ACC1", + "#1976D2", + "#512DA8", + "#5D4037" + ] +} diff --git a/palettes/monochrome.json b/palettes/monochrome.json new file mode 100644 index 0000000..cf4fcc2 --- /dev/null +++ b/palettes/monochrome.json @@ -0,0 +1,13 @@ +{ + "name": "Monochrome Tones", + "colors": [ + "#FFFFFF", + "#D0D0D0", + "#A0A0A0", + "#808080", + "#606060", + "#404040", + "#202020", + "#000000" + ] +} diff --git a/palettes/pastel.json b/palettes/pastel.json new file mode 100644 index 0000000..8091d85 --- /dev/null +++ b/palettes/pastel.json @@ -0,0 +1,13 @@ +{ + "name": "Pastel Colors", + "colors": [ + "#FFB3BA", + "#FFDFBA", + "#FFFFBA", + "#BAFFC9", + "#BAE1FF", + "#D4BAFF", + "#FFBAF3", + "#FFE5E5" + ] +} diff --git a/palettes/primary.json b/palettes/primary.json new file mode 100644 index 0000000..a5a3bf4 --- /dev/null +++ b/palettes/primary.json @@ -0,0 +1,13 @@ +{ + "name": "Primary Colors", + "colors": [ + "#FF0000", + "#FFFF00", + "#00FF00", + "#00FFFF", + "#0000FF", + "#FF00FF", + "#FFFFFF", + "#000000" + ] +} diff --git a/tests/test_color_palette.py b/tests/test_color_palette.py new file mode 100644 index 0000000..6b66715 --- /dev/null +++ b/tests/test_color_palette.py @@ -0,0 +1,72 @@ +import unittest +import json +from pathlib import Path + +from color_palette import ColorPalette + + +class TestColorPalette(unittest.TestCase): + def setUp(self): + self.node = ColorPalette() + + def test_list_presets(self): + """palettes/ に存在するプリセット一覧を取得""" + presets = self.node.list_presets() + self.assertIsInstance(presets, list) + self.assertIn("primary", presets) + self.assertIn("pastel", presets) + self.assertIn("gansai", presets) + self.assertIn("monochrome", presets) + + def test_load_primary(self): + """primary.json の8色が正しく読み込まれる""" + colors = self.node.load_palette("primary") + self.assertEqual(len(colors), 8) + self.assertTrue(all(c.startswith("#") for c in colors)) + self.assertEqual(colors[0], "#FF0000") # red + self.assertEqual(colors[6], "#FFFFFF") # white + + def test_load_monochrome(self): + """monochrome.json のグラデーションが正しい""" + colors = self.node.load_palette("monochrome") + self.assertEqual(len(colors), 8) + self.assertEqual(colors[0], "#FFFFFF") # white start + self.assertEqual(colors[7], "#000000") # black end + + def test_get_palette_output(self): + """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_nonexistent_preset_fallback(self): + """存在しないプリセットはフォールバックで8色を返す""" + colors = self.node.load_palette("nonexistent") + self.assertEqual(len(colors), 8) + self.assertTrue(all(c.startswith("#") for c in colors)) + + def test_custom_palette(self): + """カスタムパレット (custom.json) を動的作成して検証""" + custom_path = self.node.palettes_dir / "custom.json" + custom_data = { + "name": "Custom Test", + "colors": ["#AAAAAA"] * 8 + } + try: + with open(custom_path, "w", encoding="utf-8") as f: + json.dump(custom_data, f) + + # キャッシュをクリア + self.node._palette_cache.clear() + + colors = self.node.load_palette("custom") + self.assertEqual(len(colors), 8) + self.assertEqual(colors[0], "#AAAAAA") + finally: + if custom_path.exists(): + custom_path.unlink() + + +if __name__ == "__main__": + unittest.main()