489 lines
16 KiB
JavaScript
489 lines
16 KiB
JavaScript
/**
|
|
* COLOR Widget for ComfyUI
|
|
*
|
|
* This integration script is licensed under the GNU General Public License v3.0 (GPL-3.0).
|
|
* If you incorporate or modify this code, please credit AILab as the original source:
|
|
* https://github.com/1038lab/ComfyUI-RMBG
|
|
*/
|
|
|
|
import { app } from "/scripts/app.js";
|
|
|
|
const getContrastTextColor = (hexColor) => {
|
|
if (typeof hexColor !== 'string' || !/^#?[0-9a-fA-F]{6}$/.test(hexColor)) {
|
|
return '#cccccc';
|
|
}
|
|
|
|
const hex = hexColor.replace('#', '');
|
|
const r = parseInt(hex.substr(0, 2), 16);
|
|
const g = parseInt(hex.substr(2, 2), 16);
|
|
const b = parseInt(hex.substr(4, 2), 16);
|
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
|
|
|
return luminance > 0.5 ? '#333333' : '#cccccc';
|
|
};
|
|
|
|
// ── Color Conversion Helpers ──────────────────────────────────────────
|
|
|
|
function hexToRGB(hex) {
|
|
hex = hex.replace('#', '');
|
|
return {
|
|
r: parseInt(hex.substr(0, 2), 16),
|
|
g: parseInt(hex.substr(2, 2), 16),
|
|
b: parseInt(hex.substr(4, 2), 16),
|
|
};
|
|
}
|
|
|
|
function rgbToHex(r, g, b) {
|
|
return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function rgbToHSV(r, g, b) {
|
|
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, s = max === 0 ? 0 : d / max, v = max;
|
|
if (d !== 0) {
|
|
switch (max) {
|
|
case r: h = ((g - b) / d + 6) % 6; break;
|
|
case g: h = (b - r) / d + 2; break;
|
|
case b: h = (r - g) / d + 4; break;
|
|
}
|
|
h *= 60;
|
|
}
|
|
return { h, s, v };
|
|
}
|
|
|
|
function hsvToRGB(h, s, v) {
|
|
h = ((h % 360) + 360) % 360;
|
|
const c = v * s;
|
|
const x = c * (1 - Math.abs((h / 60) % 2 - 1));
|
|
const m = v - c;
|
|
let r, g, b;
|
|
if (h < 60) { r = c; g = x; b = 0; }
|
|
else if (h < 120) { r = x; g = c; b = 0; }
|
|
else if (h < 180) { r = 0; g = c; b = x; }
|
|
else if (h < 240) { r = 0; g = x; b = c; }
|
|
else if (h < 300) { r = x; g = 0; b = c; }
|
|
else { r = c; g = 0; b = x; }
|
|
return {
|
|
r: Math.round((r + m) * 255),
|
|
g: Math.round((g + m) * 255),
|
|
b: Math.round((b + m) * 255),
|
|
};
|
|
}
|
|
|
|
function hexToHSV(hex) {
|
|
const { r, g, b } = hexToRGB(hex);
|
|
return rgbToHSV(r, g, b);
|
|
}
|
|
|
|
function hsvToHex(h, s, v) {
|
|
const { r, g, b } = hsvToRGB(h, s, v);
|
|
return rgbToHex(r, g, b);
|
|
}
|
|
|
|
// ── Custom Color Picker ───────────────────────────────────────────────
|
|
|
|
function openColorPicker(initialColor, clientX, clientY, onApply) {
|
|
// Close any existing picker
|
|
const existing = document.querySelector('.ailab-cp-backdrop');
|
|
if (existing) existing.remove();
|
|
const existingPanel = document.querySelector('.ailab-cp-panel');
|
|
if (existingPanel) existingPanel.remove();
|
|
|
|
const SV_SIZE = 180;
|
|
const HUE_H = 14;
|
|
const PAD = 12;
|
|
const GAP = 8;
|
|
|
|
let hsv = hexToHSV(initialColor);
|
|
|
|
// ── Create DOM elements ──
|
|
|
|
const backdrop = document.createElement('div');
|
|
backdrop.className = 'ailab-cp-backdrop';
|
|
Object.assign(backdrop.style, {
|
|
position: 'fixed', top: '0', left: '0',
|
|
width: '100%', height: '100%', zIndex: '99998',
|
|
});
|
|
|
|
const panel = document.createElement('div');
|
|
panel.className = 'ailab-cp-panel';
|
|
Object.assign(panel.style, {
|
|
position: 'fixed', zIndex: '99999',
|
|
width: `${SV_SIZE + PAD * 2}px`,
|
|
boxSizing: 'border-box',
|
|
background: '#1e1e1e', border: '1px solid #444',
|
|
borderRadius: '8px', padding: `${PAD}px`,
|
|
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
|
|
fontFamily: 'sans-serif', fontSize: '12px', color: '#ccc',
|
|
userSelect: 'none',
|
|
});
|
|
|
|
// SV (saturation-value) canvas
|
|
const svCanvas = document.createElement('canvas');
|
|
svCanvas.width = SV_SIZE;
|
|
svCanvas.height = SV_SIZE;
|
|
Object.assign(svCanvas.style, {
|
|
display: 'block', borderRadius: '4px', cursor: 'crosshair',
|
|
marginBottom: `${GAP}px`,
|
|
});
|
|
|
|
// Hue bar canvas
|
|
const hueCanvas = document.createElement('canvas');
|
|
hueCanvas.width = SV_SIZE;
|
|
hueCanvas.height = HUE_H;
|
|
Object.assign(hueCanvas.style, {
|
|
display: 'block', borderRadius: '8px', cursor: 'pointer',
|
|
marginBottom: `${GAP}px`,
|
|
});
|
|
|
|
// Bottom row: preview swatch + eyedropper + hex input + OK button
|
|
const row = document.createElement('div');
|
|
Object.assign(row.style, {
|
|
display: 'flex', alignItems: 'center', gap: '6px',
|
|
width: '100%', boxSizing: 'border-box',
|
|
});
|
|
|
|
const preview = document.createElement('div');
|
|
Object.assign(preview.style, {
|
|
width: '26px', height: '26px', borderRadius: '4px',
|
|
border: '1px solid #555', flexShrink: '0', boxSizing: 'border-box',
|
|
});
|
|
|
|
let dropBtn = null;
|
|
if (window.EyeDropper) {
|
|
dropBtn = document.createElement('button');
|
|
dropBtn.type = 'button';
|
|
dropBtn.title = 'Pick color from screen';
|
|
Object.assign(dropBtn.style, {
|
|
width: '26px', height: '26px', borderRadius: '4px',
|
|
border: '1px solid #555', background: '#333', color: '#ccc',
|
|
flexShrink: '0', cursor: 'pointer', padding: '0',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
boxSizing: 'border-box',
|
|
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
|
|
});
|
|
dropBtn.innerHTML = `<svg width="15" height="15" viewBox="0 0 16 16" fill="currentColor" style="display:block;pointer-events:none;"><path d="M13.354.646a1.207 1.207 0 0 0-1.708 0L8.5 3.793l-.646-.647a.5.5 0 1 0-.708.708L8.293 5l-7.147 7.146A.5.5 0 0 0 1 12.5v1.793l-.854.853a.5.5 0 1 0 .708.708L1.707 15H3.5a.5.5 0 0 0 .354-.146L11 7.707l1.146 1.147a.5.5 0 0 0 .708-.708l-.647-.646 3.147-3.146a1.207 1.207 0 0 0 0-1.708zM2 12.707l7-7L10.293 7l-7 7H2z"/></svg>`;
|
|
dropBtn.addEventListener('mouseenter', () => {
|
|
dropBtn.style.background = '#444';
|
|
dropBtn.style.borderColor = '#777';
|
|
dropBtn.style.color = '#fff';
|
|
});
|
|
dropBtn.addEventListener('mouseleave', () => {
|
|
dropBtn.style.background = '#333';
|
|
dropBtn.style.borderColor = '#555';
|
|
dropBtn.style.color = '#ccc';
|
|
});
|
|
dropBtn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
try {
|
|
const eyeDropper = new window.EyeDropper();
|
|
const result = await eyeDropper.open();
|
|
if (result && result.sRGBHex) {
|
|
hsv = hexToHSV(result.sRGBHex);
|
|
updateAll();
|
|
}
|
|
} catch (err) {}
|
|
});
|
|
}
|
|
|
|
const hexInput = document.createElement('input');
|
|
hexInput.type = 'text';
|
|
Object.assign(hexInput.style, {
|
|
flex: '1', width: '0', minWidth: '0',
|
|
height: '26px', boxSizing: 'border-box',
|
|
background: '#333', border: '1px solid #555',
|
|
borderRadius: '4px', color: '#eee', padding: '0 4px',
|
|
fontFamily: 'monospace', fontSize: '11px', outline: 'none',
|
|
textAlign: 'center',
|
|
});
|
|
|
|
const okBtn = document.createElement('button');
|
|
okBtn.textContent = 'OK';
|
|
Object.assign(okBtn.style, {
|
|
height: '26px', boxSizing: 'border-box',
|
|
background: '#4a9eff', color: '#fff', border: 'none',
|
|
borderRadius: '4px', padding: '0 10px', cursor: 'pointer',
|
|
fontSize: '11px', fontWeight: 'bold', flexShrink: '0',
|
|
});
|
|
|
|
row.append(preview);
|
|
if (dropBtn) row.append(dropBtn);
|
|
row.append(hexInput, okBtn);
|
|
panel.append(svCanvas, hueCanvas, row);
|
|
|
|
// ── Position panel below click point, clamped to viewport ──
|
|
|
|
const panelW = SV_SIZE + PAD * 2;
|
|
const panelH = SV_SIZE + HUE_H + 28 + GAP * 2 + PAD * 2 + 4;
|
|
|
|
let px = clientX - panelW / 2;
|
|
let py = clientY + 10;
|
|
|
|
if (px + panelW > window.innerWidth - 10) px = window.innerWidth - panelW - 10;
|
|
if (px < 10) px = 10;
|
|
if (py + panelH > window.innerHeight - 10) py = clientY - panelH - 10;
|
|
if (py < 10) py = 10;
|
|
|
|
panel.style.left = `${px}px`;
|
|
panel.style.top = `${py}px`;
|
|
|
|
// ── Drawing functions ──
|
|
|
|
function drawSV() {
|
|
const ctx = svCanvas.getContext('2d');
|
|
const { r, g, b } = hsvToRGB(hsv.h, 1, 1);
|
|
|
|
// Base hue fill
|
|
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
ctx.fillRect(0, 0, SV_SIZE, SV_SIZE);
|
|
|
|
// White gradient (left → right = low → high saturation)
|
|
const whiteGrad = ctx.createLinearGradient(0, 0, SV_SIZE, 0);
|
|
whiteGrad.addColorStop(0, '#ffffff');
|
|
whiteGrad.addColorStop(1, 'rgba(255,255,255,0)');
|
|
ctx.fillStyle = whiteGrad;
|
|
ctx.fillRect(0, 0, SV_SIZE, SV_SIZE);
|
|
|
|
// Black gradient (top → bottom = high → low value)
|
|
const blackGrad = ctx.createLinearGradient(0, 0, 0, SV_SIZE);
|
|
blackGrad.addColorStop(0, 'rgba(0,0,0,0)');
|
|
blackGrad.addColorStop(1, '#000000');
|
|
ctx.fillStyle = blackGrad;
|
|
ctx.fillRect(0, 0, SV_SIZE, SV_SIZE);
|
|
|
|
// Crosshair cursor
|
|
const cx = hsv.s * SV_SIZE;
|
|
const cy = (1 - hsv.v) * SV_SIZE;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, 6, 0, Math.PI * 2);
|
|
ctx.strokeStyle = '#ffffff';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, 5, 0, Math.PI * 2);
|
|
ctx.strokeStyle = '#000000';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
|
|
function drawHue() {
|
|
const ctx = hueCanvas.getContext('2d');
|
|
const grad = ctx.createLinearGradient(0, 0, SV_SIZE, 0);
|
|
[0, 60, 120, 180, 240, 300, 360].forEach(deg => {
|
|
const { r, g, b } = hsvToRGB(deg, 1, 1);
|
|
grad.addColorStop(deg / 360, `rgb(${r},${g},${b})`);
|
|
});
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, SV_SIZE, HUE_H);
|
|
|
|
// Indicator thumb
|
|
const ix = (hsv.h / 360) * SV_SIZE;
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fillRect(ix - 2, 0, 4, HUE_H);
|
|
ctx.strokeStyle = '#333333';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(ix - 2, 0, 4, HUE_H);
|
|
}
|
|
|
|
function updateAll() {
|
|
const hex = hsvToHex(hsv.h, hsv.s, hsv.v);
|
|
preview.style.backgroundColor = hex;
|
|
hexInput.value = hex;
|
|
drawSV();
|
|
drawHue();
|
|
}
|
|
|
|
// ── Interaction handlers ──
|
|
|
|
let svDrag = false, hueDrag = false;
|
|
|
|
function onSV(e) {
|
|
const rect = svCanvas.getBoundingClientRect();
|
|
hsv.s = Math.max(0, Math.min(1, (e.clientX - rect.left) / SV_SIZE));
|
|
hsv.v = Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / SV_SIZE));
|
|
updateAll();
|
|
}
|
|
|
|
function onHue(e) {
|
|
const rect = hueCanvas.getBoundingClientRect();
|
|
hsv.h = Math.max(0, Math.min(360, ((e.clientX - rect.left) / SV_SIZE) * 360));
|
|
updateAll();
|
|
}
|
|
|
|
function onPointerMove(e) {
|
|
if (svDrag) onSV(e);
|
|
if (hueDrag) onHue(e);
|
|
}
|
|
|
|
function onPointerUp() {
|
|
svDrag = false;
|
|
hueDrag = false;
|
|
}
|
|
|
|
svCanvas.addEventListener('pointerdown', (e) => {
|
|
svDrag = true; onSV(e);
|
|
e.preventDefault(); e.stopPropagation();
|
|
});
|
|
hueCanvas.addEventListener('pointerdown', (e) => {
|
|
hueDrag = true; onHue(e);
|
|
e.preventDefault(); e.stopPropagation();
|
|
});
|
|
|
|
document.addEventListener('pointermove', onPointerMove);
|
|
document.addEventListener('pointerup', onPointerUp);
|
|
|
|
// Hex input: live update on valid input
|
|
hexInput.addEventListener('input', () => {
|
|
const val = hexInput.value.trim();
|
|
if (/^#?[0-9a-fA-F]{6}$/.test(val)) {
|
|
const hex = val.startsWith('#') ? val : '#' + val;
|
|
hsv = hexToHSV(hex);
|
|
preview.style.backgroundColor = hex;
|
|
drawSV();
|
|
drawHue();
|
|
}
|
|
});
|
|
|
|
// Prevent keystrokes from reaching ComfyUI canvas
|
|
hexInput.addEventListener('keydown', (e) => e.stopPropagation());
|
|
|
|
// Prevent panel clicks from reaching backdrop
|
|
panel.addEventListener('pointerdown', (e) => e.stopPropagation());
|
|
|
|
// ── Close / Apply ──
|
|
|
|
function close() {
|
|
document.removeEventListener('pointermove', onPointerMove);
|
|
document.removeEventListener('pointerup', onPointerUp);
|
|
backdrop.remove();
|
|
panel.remove();
|
|
}
|
|
|
|
backdrop.addEventListener('pointerdown', (e) => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
onApply(hsvToHex(hsv.h, hsv.s, hsv.v));
|
|
close();
|
|
});
|
|
|
|
okBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
onApply(hsvToHex(hsv.h, hsv.s, hsv.v));
|
|
close();
|
|
});
|
|
|
|
// ── Mount & render ──
|
|
|
|
document.body.append(backdrop, panel);
|
|
updateAll();
|
|
}
|
|
|
|
// ── ComfyUI Widget Definition ─────────────────────────────────────────
|
|
|
|
const AILabColorWidget = {
|
|
COLORCODE: (key, val) => {
|
|
const widget = {};
|
|
widget.y = 0;
|
|
widget.name = key;
|
|
widget.type = 'COLORCODE';
|
|
|
|
const defaultColor = '#222222';
|
|
widget.options = { default: defaultColor };
|
|
|
|
let initialValue = defaultColor;
|
|
if (Array.isArray(val) && val.length > 1 && val[1] && val[1].default) {
|
|
initialValue = val[1].default;
|
|
}
|
|
|
|
if (typeof initialValue === 'string' && /^#?[0-9a-fA-F]{6}$/.test(initialValue)) {
|
|
widget.value = initialValue;
|
|
} else {
|
|
widget.value = defaultColor;
|
|
}
|
|
|
|
|
|
widget.draw = function (ctx, node, widgetWidth, widgetY, height) {
|
|
const hide = this.type !== 'COLORCODE' && app.canvas.ds.scale > 0.5;
|
|
if (hide) {
|
|
return;
|
|
}
|
|
|
|
const actualWidth = node.size[0];
|
|
const drawHeight = 22;
|
|
const margin = 15;
|
|
const radius = 10;
|
|
|
|
ctx.fillStyle = this.value;
|
|
ctx.beginPath();
|
|
const x = margin;
|
|
const y = widgetY + (height - drawHeight) / 2;
|
|
const w = actualWidth - margin * 2;
|
|
const h = drawHeight;
|
|
ctx.moveTo(x + radius, y);
|
|
ctx.lineTo(x + w - radius, y);
|
|
ctx.arcTo(x + w, y, x + w, y + radius, radius);
|
|
ctx.lineTo(x + w, y + h - radius);
|
|
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
|
|
ctx.lineTo(x + radius, y + h);
|
|
ctx.arcTo(x, y + h, x, y + h - radius, radius);
|
|
ctx.lineTo(x, y + radius);
|
|
ctx.arcTo(x, y, x + radius, y, radius);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
ctx.strokeStyle = '#555';
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = getContrastTextColor(this.value);
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
|
|
const text = `${this.name} (${this.value})`;
|
|
ctx.fillText(text, actualWidth * 0.5, y + drawHeight * 0.65);
|
|
};
|
|
|
|
widget.mouse = function (e, pos, node) {
|
|
if (e.type === 'pointerdown') {
|
|
const margin = 15;
|
|
|
|
if (pos[0] >= margin && pos[0] <= node.size[0] - margin) {
|
|
const widgetRef = this;
|
|
openColorPicker(this.value, e.clientX, e.clientY, (newColor) => {
|
|
widgetRef.value = newColor;
|
|
widgetRef.callback?.(newColor);
|
|
node.graph._version++;
|
|
node.setDirtyCanvas(true, true);
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
widget.computeSize = function () {
|
|
return [50, 22];
|
|
};
|
|
|
|
return widget;
|
|
}
|
|
};
|
|
|
|
app.registerExtension({
|
|
name: "AILab.colorWidget",
|
|
|
|
getCustomWidgets() {
|
|
return {
|
|
COLORCODE: (node, inputName, inputData) => {
|
|
return {
|
|
widget: node.addCustomWidget(
|
|
AILabColorWidget.COLORCODE(inputName, inputData)
|
|
),
|
|
minWidth: 150,
|
|
minHeight: 22,
|
|
};
|
|
}
|
|
};
|
|
}
|
|
}); |