feat: initial release of TJ_ComfyUI_ColorUtility (RGB/HSV/Alpha picker)

This commit is contained in:
TJ16th
2025-11-10 01:33:07 +09:00
commit 523221c028
9 changed files with 723 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.env
.venv/
# ComfyUI cache/temp
cache/
logs/
*.log
# OS / Editor
.DS_Store
Thumbs.db
.vscode/
.idea/
# Node build artifacts (future-proof)
dist/
build/
# Misc
*.bak
*.tmp
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 TJ16th
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+43
View File
@@ -0,0 +1,43 @@
# TJ_ComfyUI_ColorUtility
高精度な RGB/HSV/Alpha カラーピッカーを ComfyUI のカスタムノードとして提供します。
## 特長
- 色相環 + 中央 SV 正方形 (ピクセル単位 HSV→RGB 変換により端の純白/純黒を保証)
- アルファバー + RGBA プレビュー (チェック柄背景)
- #RRGGBB と #RRGGBBAA の 2 出力 (互換性維持のため従来 hex_color も提供)
- ズーム/パン追従する安定したオーバーレイ位置計算
- HiDPI / スケール対応 (devicePixelRatio 利用)
- 端スナップ (左上=白 / 右下=黒) により色の再現性を確保
## インストール
1. ComfyUI の `custom_nodes` ディレクトリへ本リポジトリを配置 (もしくは git clone)。
```powershell
cd <ComfyUI>/custom_nodes
git clone https://github.com/TJ16th/TJ_ComfyUI_ColorUtility.git tj_ComfyUIUtil
```
2. ComfyUI を再起動。
3. ノード検索で `RGB Color Picker` を追加。
## 使い方
- RGB スライダーはピッカー操作で自動更新されます。
- 色相:リング部分をドラッグ
- 彩度/明度:中央の正方形をドラッグ (端/隅で純白/純黒を取得)
- アルファ:下部バーを左右へドラッグ
- 出力:
- `hex_color` : `#RRGGBB`
- `hex_color_rgba` : `#RRGGBBAA`
## アップデート計画 (例)
- キーボード操作対応
- 可変リング太さ/サイズ設定
- HSV / HSL 数値ウィジェット追加
## ライセンス
本リポジトリは MIT License です。詳細は `LICENSE` を参照してください。
## 貢献
Issue / Pull Request 歓迎です。バグ報告には再現手順と環境情報 (OS, ブラウザ, ComfyUI バージョン) を添えてください。
---
Created by TJ16th. Enjoy creative coloring!
+113
View File
@@ -0,0 +1,113 @@
# RGBカラーピッカーノード 仕様書
## 概要
RGBスライダーとカラープレビューを備えたComfyUIカスタムノード。
ユーザーがRGB値を視覚的に選択でき、HEX形式のカラーコードを出力する。
## ノード名
`RGBColorPicker`
## 入力パラメータ
| パラメータ名 | 型 | 範囲 | デフォルト値 | 説明 |
|------------|------|------|------------|------|
| red | INT | 0-255 | 255 | 赤色の値 |
| green | INT | 0-255 | 0 | 緑色の値 |
| blue | INT | 0-255 | 0 | 青色の値 |
## 出力
| 出力名 | 型 | 形式 | 説明 |
|--------|------|------|------|
| hex_color | STRING | #RRGGBB | HEX形式のカラーコード(例: #FF0000) |
## UI機能
1. **RGBスライダー**
- 各色(R/G/B)に対応する3つのスライダー
- 範囲: 0-255
- リアルタイムでカラープレビューに反映
2. **ビジュアルカラーピッカー**
- カラーホイール(色相環)
- 中央の明度・彩度グラデーションパネル
- インタラクティブな色選択
- マウスクリック/ドラッグで色を選択
- 選択した色をRGBスライダーに反映
3. **カラープレビュー**
- 現在選択されているRGB値の色を表示
- スライダー操作に応じて即座に更新
- ビジュアルピッカーの操作にも連動
## 動作仕様
1. ユーザーが各スライダーでRGB値を設定
2. RGB値(R, G, B)をHEX形式(#RRGGBB)に変換
3. 各8bit値を16進数2桁に変換して連結
4. HEX文字列を出力として返す
### 変換例
- RGB(255, 0, 0) → `#FF0000` (赤)
- RGB(0, 255, 0) → `#00FF00` (緑)
- RGB(0, 0, 255) → `#0000FF` (青)
- RGB(171, 205, 239) → `#ABCDEF`
- RGB(255, 255, 255) → `#FFFFFF` (白)
- RGB(0, 0, 0) → `#000000` (黒)
## カテゴリ
`TJnodes/color`
## 技術仕様
### Python側
- 言語: Python
- フレームワーク: ComfyUI Custom Node API
- 必要なライブラリ: なし(標準ライブラリのみ)
### JavaScript側
- Canvas API: カラーホイールとグラデーション描画
- DOM API: インタラクティブUI構築
- 必要なライブラリ: なし(vanilla JS)
- 色空間変換: RGB ⇔ HSV カスタム実装
## ファイル構成
```
tj_ComfyUIUtil/
├── __init__.py # ノード登録とWEB_DIRECTORYのエクスポート
├── rgb_color_picker.py # メインノード実装
├── js/
│ └── rgb_color_picker.js # カラープレビュー用JavaScript拡張
└── SPEC.md # 本仕様書
```
## 実装のポイント
### Python側 (rgb_color_picker.py)
- クラス名: `RGBColorPicker`
- `INPUT_TYPES`でスライダー(0-255)を定義
- `"red": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1})`
- `"green": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1})`
- `"blue": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1})`
- `RETURN_TYPES = ("STRING",)`
- `RETURN_NAMES = ("hex_color",)`
- `FUNCTION = "convert_to_hex"`
- RGB→HEX変換は`"#{:02X}{:02X}{:02X}".format(r, g, b)`を使用
- カテゴリは`"TJnodes/color"`
### JavaScript側 (js/rgb_color_picker.js)
- `app.registerExtension`で拡張を登録
- `beforeRegisterNodeDef`フックでノードタイプを検出(`nodeData.name === "RGBColorPicker"`)
- カスタムウィジェットとして以下を実装:
1. **カラーホイール(色相環)**: Canvas APIで描画
2. **明度・彩度グラデーション**: 中央の矩形領域
3. **カラープレビュー**: 選択色の表示
4. **インタラクション**:
- マウスクリック/ドラッグでホイールと中央パネルから色を選択
- RGB値とHSV値の相互変換
- スライダー変更時にピッカーUIを更新
- ピッカー操作時にスライダーを更新
- ウィジェットの`draw`メソッドでDOM要素の位置を更新
- RGB ⇔ HSV 変換関数を実装
- 参考: Pickr.js のようなカラーピッカーUI
### __init__.py
- `NODE_CLASS_MAPPINGS`でノードクラスをマッピング
- `NODE_DISPLAY_NAME_MAPPINGS`で表示名を設定
- `WEB_DIRECTORY = "./js"`をエクスポート
- `__all__`に`WEB_DIRECTORY`を含める
+14
View File
@@ -0,0 +1,14 @@
from .rgb_color_picker import RGBColorPicker
NODE_CLASS_MAPPINGS = {
"RGBColorPicker": RGBColorPicker,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RGBColorPicker": "RGB Color Picker",
}
# Serve frontend JS from the js/ folder
WEB_DIRECTORY = "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+3
View File
@@ -0,0 +1,3 @@
// Positioning debug overlay (A–C–D) has been retired.
// Intentionally left blank to avoid loading legacy UI.
export {};
+424
View File
@@ -0,0 +1,424 @@
// Use absolute import so it works regardless of extension folder depth
import { app } from "/scripts/app.js";
/*
RGB Color Picker extension for ComfyUI
- Adds a DOM-based color wheel + SV square widget to the RGBColorPicker node
- Syncs with the node's red/green/blue INT widgets
- Outputs hex via Python node
*/
const EXT_NAME = "Comfy.TJnodes.RGBColorPicker";
const __RGB_PICKER_VERSION__ = "v0.1.1-debug";
console.log(`[${EXT_NAME}] loading script ${__RGB_PICKER_VERSION__}`);
function clamp(v, min, max) { return v < min ? min : (v > max ? max : v); }
function hsvToRgb(h, s, v) {
// h: [0,360), s,v: [0,1]
const c = v * s;
const hh = (h / 60) % 6;
const x = c * (1 - Math.abs((hh % 2) - 1));
let r1 = 0, g1 = 0, b1 = 0;
if (0 <= hh && hh < 1) { r1 = c; g1 = x; b1 = 0; }
else if (1 <= hh && hh < 2) { r1 = x; g1 = c; b1 = 0; }
else if (2 <= hh && hh < 3) { r1 = 0; g1 = c; b1 = x; }
else if (3 <= hh && hh < 4) { r1 = 0; g1 = x; b1 = c; }
else if (4 <= hh && hh < 5) { r1 = x; g1 = 0; b1 = c; }
else { r1 = c; g1 = 0; b1 = x; }
const m = v - c;
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
}
function rgbToHsv(r, g, b) {
// r,g,b: [0,255]
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d === 0) h = 0;
else if (max === r) h = 60 * (((g - b) / d) % 6);
else if (max === g) h = 60 * (((b - r) / d) + 2);
else h = 60 * (((r - g) / d) + 4);
if (h < 0) h += 360;
const s = max === 0 ? 0 : d / max;
const v = max;
return [h, s, v];
}
function createContainer() {
const container = document.createElement("div");
// Follow LogiCalc HTMLViewer strategy: fixed positioning inside canvas container
container.style.position = "fixed";
container.style.zIndex = "100";
container.style.width = "300px";
container.style.pointerEvents = "auto"; // allow interaction
container.style.transformOrigin = "top left"; // for zoom scaling
// Preview swatch (with checkerboard + overlay swatch)
const preview = document.createElement("div");
preview.style.width = "56px";
preview.style.height = "56px";
preview.style.border = "1px solid #fff";
preview.style.borderRadius = "6px";
preview.style.marginTop = "8px";
preview.style.position = "relative";
// checkerboard background
preview.style.backgroundImage = `
linear-gradient(45deg, #999 25%, transparent 25%),
linear-gradient(-45deg, #999 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #999 75%),
linear-gradient(-45deg, transparent 75%, #999 75%)`;
preview.style.backgroundSize = "12px 12px";
preview.style.backgroundPosition = "0 0, 0 6px, 6px -6px, -6px 0px";
const swatch = document.createElement("div");
swatch.style.position = "absolute";
swatch.style.inset = "0";
swatch.style.borderRadius = "6px";
// Single canvas: hue ring + inner SV map (HiDPI aware)
const wheel = document.createElement("canvas");
const baseSize = 300;
const dpr = window.devicePixelRatio || 1;
wheel.style.width = `${baseSize}px`;
wheel.style.height = `${baseSize}px`;
wheel.width = Math.round(baseSize * dpr);
wheel.height = Math.round(baseSize * dpr);
wheel.style.display = "block";
// Alpha bar under wheel
const alphaBar = document.createElement("canvas");
const alphaBarHeight = 16;
alphaBar.style.width = `${baseSize}px`;
alphaBar.style.height = `${alphaBarHeight}px`;
alphaBar.width = Math.round(baseSize * dpr);
alphaBar.height = Math.round(alphaBarHeight * dpr);
alphaBar.style.display = "block";
alphaBar.style.marginTop = "8px";
container.appendChild(wheel);
container.appendChild(alphaBar);
preview.appendChild(swatch);
container.appendChild(preview);
const canvas = app.canvas?.canvas;
const canvasContainer = canvas?.parentElement || document.body;
canvasContainer.appendChild(container);
return { container, wheel, alphaBar, preview, swatch };
}
function drawHueRingAndSV(canvas, hueDeg, s, v, markers) {
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
// Work in CSS pixels for geometry; scale context for HiDPI
const w = canvas.clientWidth || parseInt(canvas.style.width) || 300;
const h = canvas.clientHeight || parseInt(canvas.style.height) || 300;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const cx = w / 2, cy = h / 2;
const outer = Math.min(cx, cy) - 4; // outer radius
const ringThickness = 20;
const inner = outer - ringThickness; // inner radius -> inside will host SV
// Hue ring (anti-aliased) using conic gradient when available
if (typeof ctx.createConicGradient === 'function') {
const cg = ctx.createConicGradient(-Math.PI/2, cx, cy); // start at top
cg.addColorStop(0/6, '#ff0000');
cg.addColorStop(1/6, '#ffff00');
cg.addColorStop(2/6, '#00ff00');
cg.addColorStop(3/6, '#00ffff');
cg.addColorStop(4/6, '#0000ff');
cg.addColorStop(5/6, '#ff00ff');
cg.addColorStop(1, '#ff0000');
ctx.fillStyle = cg;
ctx.beginPath();
ctx.arc(cx, cy, outer, 0, Math.PI * 2);
ctx.arc(cx, cy, inner, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill('evenodd');
} else {
// Fallback: coarse pixel method
const image = ctx.createImageData(w, h);
for (let yy = 0; yy < h; yy++) {
for (let xx = 0; xx < w; xx++) {
const dx = xx - cx; const dy = yy - cy;
const dist = Math.sqrt(dx*dx + dy*dy);
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;
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 {
image.data[idx+3] = 0;
}
}
}
ctx.putImageData(image, 0, 0);
}
// Inner SV square: compute directly from desired corner gap to keep perfectly centered
const cornerGap = 14; // distance from square corner to ring inner edge (CSS px)
// square half diagonal = inner - cornerGap -> side = (inner - cornerGap)*sqrt(2)
const side = Math.max(1, (inner - cornerGap) * Math.sqrt(2));
const topLeftX = cx - side / 2;
const topLeftY = cy - side / 2;
// radiusForSV kept for geometry compatibility (half diagonal of square)
const radiusForSV = side / Math.sqrt(2);
// Offscreen SV texture for current hue (per-pixel HSV -> RGB,正確な黒/白を保証)
const off = document.createElement("canvas");
const pxSide = Math.max(1, Math.round(side * dpr));
off.width = pxSide; off.height = pxSide;
const octx = off.getContext("2d");
const img = octx.createImageData(pxSide, pxSide);
for (let yy = 0; yy < pxSide; yy++) {
const V = 1 - (yy / (pxSide - 1));
for (let xx = 0; xx < pxSide; xx++) {
const S = xx / (pxSide - 1);
const [rr, gg, bb] = hsvToRgb(hueDeg, S, V);
const idx = (yy * pxSide + xx) * 4;
img.data[idx] = rr; img.data[idx+1] = gg; img.data[idx+2] = bb; img.data[idx+3] = 255;
}
}
octx.putImageData(img, 0, 0);
// Draw SV square directly (remove circular clip so display matches interactive square)
ctx.save();
ctx.beginPath();
ctx.rect(topLeftX, topLeftY, side, side);
ctx.clip();
// draw offscreen (device px) into CSS px area
ctx.drawImage(off, topLeftX, topLeftY, side, side);
ctx.restore();
// (debug frame removed) SV表示はクリック範囲と一致する正方形。必要なら再度描画コードを追加可能。
// Markers
const H = markers?.h ?? hueDeg; const S = markers?.s ?? s; const V = markers?.v ?? v;
// hue marker in ring centerline
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);
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;
const svY = topLeftY + (1 - V) * side;
ctx.save();
ctx.strokeStyle = "#000"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(svX, svY, 6, 0, Math.PI * 2); ctx.stroke();
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(svX, svY, 6, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
// geometry for hit tests
return { cx, cy, outer, inner, radiusForSV, side, topLeftX, topLeftY };
}
function drawAlphaBar(canvas, r, g, b, a) {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth || parseInt(canvas.style.width) || 300;
const h = canvas.clientHeight || parseInt(canvas.style.height) || 16;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// checkerboard background
const tile = document.createElement('canvas');
tile.width = tile.height = 12;
const tctx = tile.getContext('2d');
tctx.fillStyle = '#bbb'; tctx.fillRect(0,0,12,12);
tctx.fillStyle = '#999'; tctx.fillRect(0,0,6,6); tctx.fillRect(6,6,6,6);
const pattern = ctx.createPattern(tile, 'repeat');
ctx.fillStyle = pattern; ctx.fillRect(0,0,w,h);
// alpha gradient overlay
const grad = ctx.createLinearGradient(0,0,w,0);
grad.addColorStop(0, `rgba(${r},${g},${b},0)`);
grad.addColorStop(1, `rgba(${r},${g},${b},1)`);
ctx.fillStyle = grad; ctx.fillRect(0,0,w,h);
// handle
const x = Math.max(0, Math.min(1, a)) * w;
ctx.strokeStyle = '#000'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, h/2, 6, 0, Math.PI*2); ctx.stroke();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(x, h/2, 6, 0, Math.PI*2); ctx.stroke();
}
// (legacy) drawSVSquare no longer used
// Position DOM overlay using the same formula proven in TJ_LogiCalc HTMLViewer:
// (nodePos + ds.offset) * ds.scale + canvasRect.{left,top}
function positionContainer(node, ctx, cont) {
const ds = app.canvas?.ds; // {scale, offset}
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 = 12; // vertical gap
const x = nodeX; // align horizontally with node
const y = nodeY + node.size[1] * scale + gap; // place below node
cont.style.left = `${x}px`;
cont.style.top = `${y}px`;
cont.style.transform = `scale(${scale})`;
}
function attachPicker(node) {
if (node.__rgb_picker__) return node.__rgb_picker__;
const { container, wheel, alphaBar, preview, swatch } = createContainer();
let h = 330, s = 0.75, v = 0.9;
let a = 1.0;
let geometry = null; // cached geometry for hit tests
function redraw() {
geometry = drawHueRingAndSV(wheel, h, s, v, { h, s, v });
const [rr, gg, bb] = hsvToRgb(h, s, v);
drawAlphaBar(alphaBar, rr, gg, bb, a);
}
function updatePreview(updateCanvas = true) {
const [r, g, b] = hsvToRgb(h, s, v);
swatch.style.background = `rgba(${r}, ${g}, ${b}, ${a})`;
const widgets = node.widgets || [];
const wm = Object.fromEntries(widgets.map(w => [w.name, w]));
if (wm.red && wm.green && wm.blue) {
const setVal = (w, val) => { if (typeof w.value !== 'undefined') w.value = val; w.callback?.(val, app); };
setVal(wm.red, r); setVal(wm.green, g); setVal(wm.blue, b);
if (wm.alpha) setVal(wm.alpha, Math.round(a * 255));
app.graph.setDirtyCanvas(true, true);
}
if (updateCanvas) redraw();
}
redraw();
updatePreview(false);
function handlePointer(ev) {
const rect = wheel.getBoundingClientRect();
// container is scaled by ds.scale, so normalize pointer to canvas pixel coords
const ds = app.canvas?.ds;
const scale = ds?.scale || 1;
const x = (ev.clientX - rect.left) / scale;
const y = (ev.clientY - rect.top) / scale;
if (!geometry) return false;
const dx = x - geometry.cx;
const dy = y - geometry.cy;
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;
h = ang;
updatePreview();
return true;
}
// SV region: restrict strictly to the square bounds to avoid accidental hits while dragging hue
if (
x >= geometry.topLeftX && x <= geometry.topLeftX + geometry.side &&
y >= geometry.topLeftY && y <= geometry.topLeftY + geometry.side
) {
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%
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)
if (leftDist <= snapPx) s = 0;
else if (rightDist <= snapPx) s = 1;
else s = clamp(relX, 0, 1);
// 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;
}
return false;
}
function dragStart(ev) {
if (!handlePointer(ev)) return;
const move = (e) => handlePointer(e);
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
}
wheel.addEventListener('mousedown', dragStart);
function alphaStart(ev) {
const rect = alphaBar.getBoundingClientRect();
const ds = app.canvas?.ds; const scale = ds?.scale || 1;
const x = (ev.clientX - rect.left) / scale;
const w = alphaBar.clientWidth || parseInt(alphaBar.style.width) || 300;
a = clamp(x / w, 0, 1);
updatePreview();
const move = (e) => {
const xr = (e.clientX - rect.left) / scale; a = clamp(xr / w, 0, 1); updatePreview();
};
const up = () => { window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up); };
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
}
alphaBar.addEventListener('mousedown', alphaStart);
const origConfigure = node.onConfigure;
node.onConfigure = function() {
const r = this.widgets?.find(w => w.name === 'red')?.value ?? 255;
const g = this.widgets?.find(w => w.name === 'green')?.value ?? 0;
const b = this.widgets?.find(w => w.name === 'blue')?.value ?? 0;
const alphaW = this.widgets?.find(w => w.name === 'alpha')?.value;
const [hh, ss, vv] = rgbToHsv(r, g, b);
h = hh; s = ss; v = vv; if (typeof alphaW === 'number') a = clamp(alphaW / 255, 0, 1); updatePreview();
return origConfigure?.apply(this, arguments);
};
const onRemoved = node.onRemoved;
node.onRemoved = function() {
try { container.remove(); } catch {}
node.__rgb_picker__ = null;
return onRemoved?.apply(this, arguments);
};
node.__rgb_picker__ = { container, wheel, alphaBar, preview, state: () => ({ h, s, v, a }) };
return node.__rgb_picker__;
}
app.registerExtension({
name: EXT_NAME,
async beforeRegisterNodeDef(nodeType, nodeData, _app) {
console.log(`[${EXT_NAME}] beforeRegisterNodeDef: ${nodeData.name}`);
if (nodeData.name !== "RGBColorPicker") return;
const onDrawForeground = nodeType.prototype.onDrawForeground;
nodeType.prototype.onDrawForeground = function(ctx) {
const r = onDrawForeground?.apply(this, arguments);
const picker = attachPicker(this);
positionContainer(this, ctx, picker.container);
return r;
};
// Attach picker immediately when node instance is created
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const r = onNodeCreated?.apply(this, arguments);
console.log(`[${EXT_NAME}] nodeCreated id=${this.id}`);
attachPicker(this);
return r;
};
// Fallback: ensure picker after first execution if somehow missed
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function() {
const r = onExecuted?.apply(this, arguments);
if (!this.__rgb_picker__) {
console.log(`[${EXT_NAME}] onExecuted fallback attach id=${this.id}`);
attachPicker(this);
}
return r;
};
},
});
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Tuple
class RGBColorPicker:
"""
ComfyUI custom node: RGBColorPicker
- Inputs: red, green, blue (0-255), alpha (0-255)
- Output: hex_color ("#RRGGBB") # 互換性維持のためアルファは出力に含めない
- Category: TJnodes/color
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"red": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}),
"green": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1}),
"blue": ("INT", {"default": 0, "min": 0, "max": 255, "step": 1}),
"alpha": ("INT", {"default": 255, "min": 0, "max": 255, "step": 1}),
}
}
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("hex_color", "hex_color_rgba")
FUNCTION = "convert_to_hex"
CATEGORY = "TJnodes/color"
def convert_to_hex(self, red: int, green: int, blue: int, alpha: int = 255) -> Tuple[str, str]:
r = max(0, min(255, int(red)))
g = max(0, min(255, int(green)))
b = max(0, min(255, int(blue)))
a = max(0, min(255, int(alpha)))
hex_color = f"#{r:02X}{g:02X}{b:02X}"
hex_color_rgba = f"#{r:02X}{g:02X}{b:02X}{a:02X}"
return (hex_color, hex_color_rgba)
+42
View File
@@ -0,0 +1,42 @@
import unittest
from rgb_color_picker import RGBColorPicker
class TestRGBColorPicker(unittest.TestCase):
def setUp(self):
self.node = RGBColorPicker()
def check(self, rgb, expected_hex, expected_rgba=None, alpha=255):
res = self.node.convert_to_hex(*rgb, alpha)
self.assertEqual(res[0], expected_hex)
if expected_rgba is not None:
self.assertEqual(res[1], expected_rgba)
def test_primary_colors(self):
self.check((255, 0, 0), "#FF0000", "#FF0000FF")
self.check((0, 255, 0), "#00FF00", "#00FF00FF")
self.check((0, 0, 255), "#0000FF", "#0000FFFF")
def test_grayscale(self):
self.check((0, 0, 0), "#000000", "#000000FF")
self.check((255, 255, 255), "#FFFFFF", "#FFFFFFFF")
self.check((128, 128, 128), "#808080", "#808080FF")
def test_random_value(self):
self.check((171, 205, 239), "#ABCDEF", "#ABCDEFFF")
def test_alpha_variation(self):
self.check((10, 20, 30), "#0A141E", "#0A141E80", alpha=128)
self.check((10, 20, 30), "#0A141E", "#0A141E00", alpha=0)
self.check((10, 20, 30), "#0A141E", "#0A141EFF", alpha=255)
def test_clamp_low(self):
self.check((-5, 10, 10), "#000A0A", "#000A0AFF")
def test_clamp_high(self):
self.check((300, 10, 10), "#FF0A0A", "#FF0A0AFF")
if __name__ == "__main__":
unittest.main()