Fix hue wheel angle alignment and edge snap, add ColorPalette node with 8-color presets
This commit is contained in:
@@ -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` を参照してください。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
});
|
||||
+21
-9
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Gansai (Japanese Watercolor)",
|
||||
"colors": [
|
||||
"#D32F2F",
|
||||
"#F57C00",
|
||||
"#FBC02D",
|
||||
"#689F38",
|
||||
"#00ACC1",
|
||||
"#1976D2",
|
||||
"#512DA8",
|
||||
"#5D4037"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Monochrome Tones",
|
||||
"colors": [
|
||||
"#FFFFFF",
|
||||
"#D0D0D0",
|
||||
"#A0A0A0",
|
||||
"#808080",
|
||||
"#606060",
|
||||
"#404040",
|
||||
"#202020",
|
||||
"#000000"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Pastel Colors",
|
||||
"colors": [
|
||||
"#FFB3BA",
|
||||
"#FFDFBA",
|
||||
"#FFFFBA",
|
||||
"#BAFFC9",
|
||||
"#BAE1FF",
|
||||
"#D4BAFF",
|
||||
"#FFBAF3",
|
||||
"#FFE5E5"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Primary Colors",
|
||||
"colors": [
|
||||
"#FF0000",
|
||||
"#FFFF00",
|
||||
"#00FF00",
|
||||
"#00FFFF",
|
||||
"#0000FF",
|
||||
"#FF00FF",
|
||||
"#FFFFFF",
|
||||
"#000000"
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user