commit 523221c028288e36768e12f2374fdfd13c98022c Author: TJ16th Date: Mon Nov 10 01:33:07 2025 +0900 feat: initial release of TJ_ComfyUI_ColorUtility (RGB/HSV/Alpha picker) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d02ef3a --- /dev/null +++ b/.gitignore @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc9ccc0 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1ed3e3 --- /dev/null +++ b/README.md @@ -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 /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! diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..a059fc0 --- /dev/null +++ b/SPEC.md @@ -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`を含める diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ab148e7 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/js/positioning_debug.js b/js/positioning_debug.js new file mode 100644 index 0000000..abb86d4 --- /dev/null +++ b/js/positioning_debug.js @@ -0,0 +1,3 @@ +// Positioning debug overlay (A–C–D) has been retired. +// Intentionally left blank to avoid loading legacy UI. +export {}; diff --git a/js/rgb_color_picker.js b/js/rgb_color_picker.js new file mode 100644 index 0000000..3653dc4 --- /dev/null +++ b/js/rgb_color_picker.js @@ -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; + }; + }, +}); diff --git a/rgb_color_picker.py b/rgb_color_picker.py new file mode 100644 index 0000000..a70f0c9 --- /dev/null +++ b/rgb_color_picker.py @@ -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) diff --git a/tests/test_rgb_color_picker.py b/tests/test_rgb_color_picker.py new file mode 100644 index 0000000..8143010 --- /dev/null +++ b/tests/test_rgb_color_picker.py @@ -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()