Add files via upload
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
MIT License
|
||||
Copyright (c) [2025] [reeoi]
|
||||
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.
|
||||
@@ -0,0 +1,65 @@
|
||||
# ComfyUI-WebROI Capture 🖥️✂️
|
||||
|
||||
<div align="center">
|
||||
|
||||
[🇺🇸 English](#english) | [🇨🇳 中文](#chinese)
|
||||
|
||||
**Capture screen, windows, or tabs directly in your browser and send them to ComfyUI.**
|
||||
**无需服务器显卡驱动,直接在浏览器内截取屏幕、窗口或标签页,并发送给 ComfyUI。**
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<a name="english"></a>
|
||||
|
||||
## 🇺🇸 English
|
||||
|
||||
**ComfyUI-WebROI** is a custom node that enables client-side screen capturing directly within the browser. It supports **Region of Interest (ROI)** selection, allowing you to crop specific parts of your screen before sending them to the workflow.
|
||||
|
||||
Since the capture happens locally in your browser and data is sent via Base64, it is **100% Cloud Compatible** (AutoDL, RunPod, Colab, AWS) and requires **no GUI or display drivers** on the server side.
|
||||
|
||||
### ✨ Key Features
|
||||
|
||||
* **🖥️ Client-Side Capture**: Uses the browser's native `navigator.mediaDevices` API. No external software (like OBS) needed.
|
||||
* **🖱️ Interactive ROI Cropping**:
|
||||
* **Draw**: Drag your mouse on the node preview to draw a selection box (Yellow).
|
||||
* **Lock**: Release mouse to lock the crop area (Red). Only this area is sent to the backend.
|
||||
* **Reset**: Double-click or press the `RESET` button to clear the selection.
|
||||
* **📐 Resizable UI**: The node is fully resizable. You can shrink it to save space on your canvas, and the preview will adapt automatically.
|
||||
* **☁️ Cloud Ready**: Works perfectly on headless cloud servers. As long as you can see the ComfyUI web page, you can stream your screen.
|
||||
* **⚡ Real-time Workflow**: Compatible with ComfyUI's `Auto Queue` for real-time Screen-to-Image generation.
|
||||
|
||||
### ⚠️ CRITICAL: HTTPS Requirement
|
||||
|
||||
**If you are using a remote server (Cloud), you MUST access ComfyUI via HTTPS.**
|
||||
|
||||
Modern browsers **block** screen sharing APIs on insecure (HTTP) connections.
|
||||
* ✅ `http://localhost` or `http://127.0.0.1` -> **Works** (Local)
|
||||
* ✅ `https://your-cloud-url.com` -> **Works** (Cloud with SSL)
|
||||
* ❌ `http://192.168.x.x` or `http://xx.xx.xx.xx:8188` -> **BLOCKED**
|
||||
|
||||
**How to fix on Cloud:**
|
||||
1. Use the **Cloudflare Tunnel** or **Ngrok** to get an `https` link.
|
||||
2. Use the official Proxy Link provided by services like RunPod.
|
||||
3. (Testing only) Enable `chrome://flags/#unsafely-treat-insecure-origin-as-secure` in Chrome and add your HTTP URL.
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
1. Navigate to your `custom_nodes` folder:
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
2. Clone this repository:
|
||||
git clone https://github.com/reeoi/ComfyUI-WebROI.git
|
||||
3. Restart ComfyUI.
|
||||
|
||||
📖 Usage
|
||||
1. Add Node: Double-click and search for WebROICapture.
|
||||
2. Start Capture: Click the 🖥️ START button on the node. Your browser will ask which screen/window to share.
|
||||
3. Select Region (Optional):
|
||||
1)Drag on the video preview to draw a box.
|
||||
2)The generated image will only contain the selected area.
|
||||
3)Double-click to reset to full view.
|
||||
4.Generate: Connect the IMAGE output to your workflow and click Queue Prompt.
|
||||
5.Real-time: Check Auto Queue in the ComfyUI menu to stream frames continuously.
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from .web_roi import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
# 这一行告诉 ComfyUI:前端文件在当前目录下的 js 文件夹里
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS', 'WEB_DIRECTORY']
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "ComfyUI.WebROI",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== "WebROICapture") return;
|
||||
|
||||
console.log("✅ WebROI: V5 Mouse Selection Loaded");
|
||||
|
||||
// ============================================================
|
||||
// 1. 绘制函数 (负责画视频、画选框、截图)
|
||||
// ============================================================
|
||||
nodeType.prototype.onDrawForeground = function(ctx) {
|
||||
if (this.flags.collapsed) return;
|
||||
|
||||
// 绘制左上角红点 (运行指示器)
|
||||
ctx.fillStyle = "red";
|
||||
ctx.beginPath();
|
||||
ctx.arc(10, 10, 5, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
const roi = this.webRoiCtx;
|
||||
if (!roi) return;
|
||||
|
||||
// 绘制状态文字
|
||||
if (roi.statusText) {
|
||||
ctx.fillStyle = "white";
|
||||
ctx.font = "14px Arial";
|
||||
ctx.fillText(roi.statusText, 20, 50);
|
||||
}
|
||||
|
||||
// 如果没有视频,就不继续了
|
||||
if (!roi.isSharing || !roi.video || roi.video.readyState < 2) return;
|
||||
|
||||
try {
|
||||
// --- A. 计算显示区域 ---
|
||||
const nodeWidth = this.size[0];
|
||||
const contentWidth = nodeWidth - 20; // 左右留边
|
||||
const aspectRatio = roi.video.videoHeight / roi.video.videoWidth;
|
||||
const contentHeight = contentWidth * aspectRatio;
|
||||
|
||||
// 记录视频在节点上的显示位置 (供鼠标事件使用)
|
||||
const drawX = 10;
|
||||
const drawY = 60; // 避开按钮
|
||||
roi.renderRect = { x: drawX, y: drawY, w: contentWidth, h: contentHeight };
|
||||
|
||||
// --- B. 绘制视频到底层 ---
|
||||
ctx.drawImage(roi.video, drawX, drawY, contentWidth, contentHeight);
|
||||
|
||||
// --- C. 绘制选框 (UI) ---
|
||||
// 计算当前的选框数据 (相对于节点坐标)
|
||||
let selRect = null;
|
||||
|
||||
// 如果正在拖拽,优先显示拖拽框
|
||||
if (roi.dragStart && roi.dragCurrent) {
|
||||
const x = Math.min(roi.dragStart[0], roi.dragCurrent[0]);
|
||||
const y = Math.min(roi.dragStart[1], roi.dragCurrent[1]);
|
||||
const w = Math.abs(roi.dragCurrent[0] - roi.dragStart[0]);
|
||||
const h = Math.abs(roi.dragCurrent[1] - roi.dragStart[1]);
|
||||
selRect = { x, y, w, h };
|
||||
|
||||
// 拖拽时画黄色虚线
|
||||
ctx.strokeStyle = "yellow";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeRect(x + drawX, y + drawY, w, h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
// 如果有已锁定的选区
|
||||
else if (roi.selection) {
|
||||
// selection 存储的是相对比例 (0.0 - 1.0),需要转回像素
|
||||
const x = roi.selection.x * contentWidth;
|
||||
const y = roi.selection.y * contentHeight;
|
||||
const w = roi.selection.w * contentWidth;
|
||||
const h = roi.selection.h * contentHeight;
|
||||
|
||||
// 锁定后画红色实线
|
||||
ctx.strokeStyle = "#ff0000";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeRect(x + drawX, y + drawY, w, h);
|
||||
}
|
||||
|
||||
// --- D. 截取数据 (Crop & Send) ---
|
||||
// 只有当尺寸变化或每一帧都更新时执行
|
||||
const vidW = roi.video.videoWidth;
|
||||
const vidH = roi.video.videoHeight;
|
||||
|
||||
// 确定源坐标 (Source X, Y, W, H)
|
||||
let sx = 0, sy = 0, sW = vidW, sH = vidH;
|
||||
|
||||
if (roi.selection) {
|
||||
sx = Math.floor(roi.selection.x * vidW);
|
||||
sy = Math.floor(roi.selection.y * vidH);
|
||||
sW = Math.floor(roi.selection.w * vidW);
|
||||
sH = Math.floor(roi.selection.h * vidH);
|
||||
}
|
||||
|
||||
// 安全检查,防止超出边界
|
||||
if (sW <= 0) sW = 1; if (sH <= 0) sH = 1;
|
||||
|
||||
// 调整画布尺寸以适应裁剪后的大小
|
||||
if (roi.cropCanvas.width !== sW || roi.cropCanvas.height !== sH) {
|
||||
roi.cropCanvas.width = sW;
|
||||
roi.cropCanvas.height = sH;
|
||||
}
|
||||
|
||||
// 执行裁剪绘制: drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh)
|
||||
roi.cropCtx.drawImage(roi.video, sx, sy, sW, sH, 0, 0, sW, sH);
|
||||
|
||||
// 更新数据给 Python
|
||||
const widget = this.widgets.find(w => w.name === "image_data");
|
||||
if (widget) {
|
||||
widget.value = roi.cropCanvas.toDataURL("image/jpeg", 0.7);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error("Draw error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 2. 鼠标交互逻辑
|
||||
// ============================================================
|
||||
|
||||
// 按下鼠标
|
||||
nodeType.prototype.onMouseDown = function(e, pos) {
|
||||
const roi = this.webRoiCtx;
|
||||
if (!roi || !roi.renderRect) return;
|
||||
|
||||
// 检查点击是否在视频区域内
|
||||
const r = roi.renderRect;
|
||||
const mx = pos[0];
|
||||
const my = pos[1];
|
||||
|
||||
if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) {
|
||||
// 记录相对于视频左上角的坐标
|
||||
roi.dragStart = [mx - r.x, my - r.y];
|
||||
roi.dragCurrent = [mx - r.x, my - r.y];
|
||||
roi.selection = null; // 清除旧选区
|
||||
return true; // 捕获事件,防止拖动节点
|
||||
}
|
||||
};
|
||||
|
||||
// 移动鼠标
|
||||
nodeType.prototype.onMouseMove = function(e, pos) {
|
||||
const roi = this.webRoiCtx;
|
||||
if (!roi || !roi.dragStart) return;
|
||||
|
||||
const r = roi.renderRect;
|
||||
// 限制坐标在视频范围内
|
||||
let currX = pos[0] - r.x;
|
||||
let currY = pos[1] - r.y;
|
||||
|
||||
// Clamp
|
||||
currX = Math.max(0, Math.min(currX, r.w));
|
||||
currY = Math.max(0, Math.min(currY, r.h));
|
||||
|
||||
roi.dragCurrent = [currX, currY];
|
||||
};
|
||||
|
||||
// 松开鼠标
|
||||
nodeType.prototype.onMouseUp = function(e, pos) {
|
||||
const roi = this.webRoiCtx;
|
||||
if (!roi || !roi.dragStart) return;
|
||||
|
||||
// 计算最终选区
|
||||
const x1 = roi.dragStart[0];
|
||||
const y1 = roi.dragStart[1];
|
||||
const x2 = roi.dragCurrent[0];
|
||||
const y2 = roi.dragCurrent[1];
|
||||
|
||||
const x = Math.min(x1, x2);
|
||||
const y = Math.min(y1, y2);
|
||||
const w = Math.abs(x1 - x2);
|
||||
const h = Math.abs(y1 - y2);
|
||||
|
||||
// 如果选区太小(比如只是点了一下),视为取消选区
|
||||
if (w > 10 && h > 10) {
|
||||
// 保存为相对比例 (0.0 - 1.0),这样分辨率改变也不怕
|
||||
roi.selection = {
|
||||
x: x / roi.renderRect.w,
|
||||
y: y / roi.renderRect.h,
|
||||
w: w / roi.renderRect.w,
|
||||
h: h / roi.renderRect.h
|
||||
};
|
||||
} else {
|
||||
roi.selection = null;
|
||||
}
|
||||
|
||||
roi.dragStart = null;
|
||||
roi.dragCurrent = null;
|
||||
};
|
||||
|
||||
// 双击重置
|
||||
nodeType.prototype.onDblClick = function(e, pos) {
|
||||
const roi = this.webRoiCtx;
|
||||
if (roi) {
|
||||
roi.selection = null;
|
||||
console.log("Selection Reset");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. 强制高度计算 (同 V4)
|
||||
// ============================================================
|
||||
const origComputeSize = nodeType.prototype.computeSize;
|
||||
nodeType.prototype.computeSize = function() {
|
||||
if (this.webRoiCtx && this.webRoiCtx.isSharing && this.webRoiCtx.video) {
|
||||
const vid = this.webRoiCtx.video;
|
||||
if (vid.videoWidth > 0) {
|
||||
const ar = vid.videoHeight / vid.videoWidth;
|
||||
return [this.size[0], (this.size[0] - 20) * ar + 100];
|
||||
}
|
||||
}
|
||||
return origComputeSize ? origComputeSize.apply(this, arguments) : [200, 100];
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// 4. 初始化与按钮
|
||||
// ============================================================
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||
|
||||
this.webRoiCtx = {
|
||||
video: document.createElement("video"),
|
||||
cropCanvas: document.createElement("canvas"),
|
||||
cropCtx: null,
|
||||
statusText: "Click Start ->",
|
||||
isSharing: false,
|
||||
renderRect: null, // 视频在节点上的位置
|
||||
selection: null, // 最终选区 {x,y,w,h} (比例 0-1)
|
||||
dragStart: null, // 拖拽起点
|
||||
dragCurrent: null // 拖拽终点
|
||||
};
|
||||
|
||||
const roi = this.webRoiCtx;
|
||||
roi.cropCtx = roi.cropCanvas.getContext("2d");
|
||||
roi.video.autoplay = true;
|
||||
roi.video.muted = true;
|
||||
roi.video.style.display = "none";
|
||||
document.body.appendChild(roi.video);
|
||||
|
||||
// 按钮 1: 开始捕捉
|
||||
this.addWidget("button", "🖥️ START", null, () => {
|
||||
startScreenShare(this, roi);
|
||||
});
|
||||
|
||||
// 按钮 2: 重置选区
|
||||
this.addWidget("button", "❌ RESET ROI", null, () => {
|
||||
roi.selection = null;
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const w = this.widgets.find(w => w.name === "image_data");
|
||||
if (w) w.type = "hidden";
|
||||
this.setSize([360, 150]);
|
||||
}, 100);
|
||||
|
||||
return r;
|
||||
};
|
||||
|
||||
async function startScreenShare(node, roi) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { cursor: "always" }, audio: false
|
||||
});
|
||||
roi.video.srcObject = stream;
|
||||
roi.video.onloadedmetadata = () => {
|
||||
roi.video.play();
|
||||
roi.statusText = "";
|
||||
roi.isSharing = true;
|
||||
node.setSize(node.computeSize());
|
||||
renderLoop();
|
||||
};
|
||||
} catch (e) {
|
||||
roi.statusText = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLoop() {
|
||||
app.graph.setDirtyCanvas(true, false);
|
||||
requestAnimationFrame(renderLoop);
|
||||
}
|
||||
}
|
||||
});
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import re
|
||||
|
||||
class WebROICapture:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
# 必须有这个输入项,前端JS才会把图片数据塞进来
|
||||
"image_data": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
"optional": {
|
||||
# 用作触发器,每次数字变化时触发
|
||||
"trigger_always": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "forceInput": True}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "capture_screen"
|
||||
CATEGORY = "WebROI"
|
||||
|
||||
def capture_screen(self, image_data, trigger_always=0):
|
||||
try:
|
||||
# 1. 检查数据是否为空
|
||||
if not image_data or "," not in image_data:
|
||||
# 返回一张黑图防止报错
|
||||
print("⚠️ WebROI: Waiting for image data...")
|
||||
empty_img = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (empty_img,)
|
||||
|
||||
# 2. 解码 Base64
|
||||
base64_data = image_data.split(",")[1]
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
img = Image.open(BytesIO(image_bytes))
|
||||
|
||||
# 3. 转换为 ComfyUI 格式 (Tensor)
|
||||
img = img.convert("RGB")
|
||||
img_np = np.array(img).astype(np.float32) / 255.0
|
||||
img_tensor = torch.from_numpy(img_np)[None,]
|
||||
|
||||
return (img_tensor,)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ WebROI Error: {e}")
|
||||
empty_img = torch.zeros((1, 512, 512, 3), dtype=torch.float32)
|
||||
return (empty_img,)
|
||||
|
||||
# 节点映射名称,JS 必须用这个名字 "WebROICapture" 才能找到它
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"WebROICapture": WebROICapture
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"WebROICapture": "🖥️ Web ROI Capture"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user