Compare commits

..
8 Commits
Author SHA1 Message Date
Dr.Lt.Data 429d0159ad docs(tests): drop dev-only debug note and dead .claude/ path from e2e test docstring 2026-04-20 02:06:44 +09:00
Dr.Lt.Data 5bed9d5545 bump version 2026-04-20 02:01:36 +09:00
Dr.Lt.Data d74c1d0112 fix: cross-version compat for DifferentialDiffusion + E2E test harness
Resolves `AttributeError: 'DifferentialDiffusion' object has no attribute
'execute'` on older ComfyUI (<0.3.63) where the V3 schema migration has
not landed yet. The prior code called `.execute(model)[0]` directly,
breaking anyone still on the legacy `.apply(model)` API.

Introduce `modules/impact/utils.apply_differential_diffusion(model)`
which uses `hasattr` to pick the correct branch at runtime:
- `.execute` -> V3 classmethod (ComfyUI >= 0.3.63)
- `.apply`   -> legacy instance method (older)
- otherwise  -> AttributeError with upgrade guidance

Replace all 7 direct call sites (animatediff_nodes, core x2,
impact_pack x2, segs_nodes, segs_upscaler) with the helper. The
existing import guard in segs_upscaler is preserved; the helper's
deferred import means ImportError still propagates for very old
ComfyUI builds without `comfy_extras.nodes_differential_diffusion`.

Fixes GitHub issue #1177.

Also add an E2E test harness:
- docs/E2E_TEST_STRATEGY.md — isolated-launch workflow with
  --disable-all-custom-nodes + --whitelist-custom-nodes, Playwright
  smoke test, workflow execution via /prompt + /history + /view,
  verification criteria, operational notes.
- tests/e2e_dd_compat.py — reference test that runs SEGSDetailer
  with noise_mask_feather=20 on a real input image, asserting
  status==success and non-zero pixel delta between input and paste
  previews (proves the detailer loop and compat shim actually ran).
2026-04-20 02:01:32 +09:00
Dr.Lt.Data 6a517ebe06 bump version 2026-01-02 21:05:11 +09:00
Terry Jia 762fecd970 Frontend vueNode (node 2.0) support for mask rect area nodes (#1167) 2026-01-02 20:46:59 +09:00
Dr.Lt.Data 51b7dcdffa refactor(js): extract shared utilities from MaskRectArea modules
- Move readLinkedNumber, getDrawColor, computeCanvasSize to common.js
- Import shared functions in mask-rect-area.js and mask-rect-area-advanced.js
- Remove duplicate implementations from both MaskRectArea modules
- Clean up unused debug parameter in syncLinkedInputsToProperties
2025-12-30 18:30:53 +09:00
Andrés Zsögön 4c864fafb0 Enable INT node inputs and fix live preview sync for MaskRectArea nodes (#1168)
## Overview

This PR improves both MaskRectArea and MaskRectAreaAdvanced nodes by allowing them to accept values from connected INT nodes, while preserving backward compatibility with existing workflows.

It also fixes several UI and preview issues that prevented the canvas from updating correctly when values were driven by links.

Fixes #1126

## Key changes

### 1. Typed INT inputs support (backend)

- Both MaskRectArea and MaskRectAreaAdvanced now declare proper INT inputs in INPUT_TYPES.
- This allows parameters such as x, y, width, height, and blur_radius to be driven directly by other nodes.
- Existing workflows that relied on node properties remain supported via fallback logic using extra_pnginfo.

### 2. Live preview correctly updates when inputs are linked (frontend)

- The canvas preview now updates immediately when values come from linked INT nodes.
- This is achieved by synchronizing linked input values into node.properties during canvas rendering, avoiding reliance on widget callbacks or execution timing.
- This directly resolves the behavior reported in issue #1126.

### 3. Node height and layout fix (frontend)

- The node height calculation was corrected to use the actual widget layout (last_y) instead of input/output counts.
- Nodes can now both grow and shrink correctly when resized or when widget content changes.
- This removes excessive empty space below the widgets and canvas.

### 4. Widget duplication prevention

- Frontend logic now detects when widgets are already created by Python and avoids recreating them in JavaScript.
- This prevents duplicated widgets and keeps the node UI consistent.

### 5. Comment cleanup

- Remaining comments in Spanish were translated to English for consistency and maintainability.

## Backward compatibility

- Existing workflows continue to work without modification.
- Default behavior and mask output remain unchanged unless inputs are explicitly linked.

## Motivation

These changes make the nodes composable with the rest of the graph (especially math and control nodes), improve the reliability of the preview, and fix UI inconsistencies without introducing breaking changes.
2025-12-30 18:26:50 +09:00
Dr.Lt.Data 61bd8397a1 Update the dependencies in pyproject.toml to match requirements.txt 2025-11-19 00:30:20 +09:00
13 changed files with 1368 additions and 247 deletions
+433
View File
@@ -0,0 +1,433 @@
# E2E Test Strategy
End-to-end test strategy for ComfyUI Impact Pack, covering isolated server launch, baseline smoke verification, real workflow execution, and operational notes for reliable test runs.
---
## Table of Contents
1. [Purpose & Scope](#purpose--scope)
2. [Prerequisites](#prerequisites)
3. [Server Launch](#server-launch)
4. [Baseline Smoke Test](#baseline-smoke-test)
5. [Workflow Execution E2E](#workflow-execution-e2e)
6. [Verification Criteria](#verification-criteria)
7. [Operational Notes](#operational-notes)
8. [Extension Points](#extension-points)
---
## Purpose & Scope
End-to-end (E2E) testing validates ComfyUI Impact Pack as it behaves in a real ComfyUI runtime, not only through unit-level assertions. The goal is to catch regressions that surface only when the full stack is loaded: server startup, node registration, REST API responses, frontend page loading, and actual workflow execution through the `/prompt` API.
**Why isolated E2E**
- A developer's ComfyUI installation typically has many custom nodes. Any one of them can fail to import, log warnings, bind frontend routes, or shadow Impact Pack behavior.
- Running Impact Pack against a clean environment removes noise and produces reproducible results.
- `--disable-all-custom-nodes` combined with `--whitelist-custom-nodes` isolates Impact Pack (and its companion subpack) from every other installed custom node while still letting them load normally.
**What this strategy validates**
- ComfyUI server starts successfully with Impact Pack and Impact Subpack loaded.
- The frontend page is served and renders the ComfyUI application shell.
- The `/object_info` REST endpoint returns the full node catalog for both packs.
- A baseline count and a sample of expected node names are present, guarding against silent node-registration regressions.
- Detailer workflows execute through the `/prompt` API and produce non-degenerate outputs, proving the full inference path (detector → SEGSDetailer → SEGSPaste) runs end to end.
**Out of scope** (covered by [Extension Points](#extension-points))
- UI-driven node creation and connection
- Visual regression testing beyond pixel-delta sanity checks
- Performance benchmarking
---
## Prerequisites
### Environment
| Component | Requirement |
|-----------|-------------|
| Python | >= 3.12 |
| Playwright (Python) | >= 1.58.0 |
| Chromium runtime | Installed via `playwright install chromium` |
| ComfyUI repository | Checked out at the parent directory of `custom_nodes/comfyui-impact-pack` |
| Impact Pack | Installed as a custom node (this repository) |
| Impact Subpack | Installed as a custom node alongside Impact Pack (delivers Ultralytics / SAM node types) |
### Directory Layout
E2E tests assume the standard ComfyUI custom-node layout:
```
ComfyUI/
├── main.py
├── custom_nodes/
│ ├── comfyui-impact-pack/ ← this repository
│ └── comfyui-impact-subpack/ ← detector / SAM node provider
└── ...
```
### Install Commands
```bash
# From the ComfyUI repository root
pip install playwright
playwright install chromium
```
Impact Pack's own Python dependencies are expected to be installed already (see `pyproject.toml` / `requirements.txt`). Impact Subpack contributes its own dependency list — install it with its documented procedure.
### Model Assets
Detailer-dependent workflows require model files on disk. Place them at the ComfyUI-relative paths below:
| Path | Purpose |
|------|---------|
| `models/checkpoints/SD1.5/realcartoonPixar_v8.safetensors` | SD1.5 checkpoint used by `CheckpointLoaderSimple` (any compatible SD1.5 checkpoint works; adjust the workflow's `ckpt_name` accordingly) |
| `models/ultralytics/bbox/face_yolov8m.pt` | Face bbox detector used by `UltralyticsDetectorProvider` |
| `models/sams/sam_vit_b_01ec64.pth` | SAM weights used when a workflow includes `SAMLoader` |
| `input/ComfyUI_00156_.png` | Portrait with a clearly visible face, used by `LoadImage` in the reference workflow |
The Ultralytics and SAM node classes themselves ship with Impact Subpack — installing the subpack is the delivery vehicle for those node types, separate from the model weights above.
Pure [baseline smoke](#baseline-smoke-test) testing does not need any of these assets; they are required only once a workflow hits a detector or loader node.
---
## Server Launch
### Default Launch
Launch an isolated ComfyUI instance with Impact Pack and Impact Subpack as the only active custom nodes. This is the default for every test beyond the pure smoke layer:
```bash
# Working directory: the ComfyUI repository root (parent of custom_nodes/)
python main.py \
--disable-all-custom-nodes \
--whitelist-custom-nodes comfyui-impact-pack comfyui-impact-subpack \
--port 18188
```
Most detailer workflows depend on `UltralyticsDetectorProvider`, `SAMLoader`, and related detector/segmenter node types shipped by Impact Subpack. Running without the subpack leaves those node classes unregistered, and any workflow referencing them will fail at prompt validation.
### Minimal Launch (smoke only)
For the pure API smoke path — page load + `/object_info` inspection, no workflow execution — Impact Pack alone is sufficient:
```bash
python main.py \
--disable-all-custom-nodes \
--whitelist-custom-nodes comfyui-impact-pack \
--port 18188
```
This minimal launch is **insufficient for detection-dependent tests**. Use it only when the test consists of startup + `/object_info` inspection.
### Flag Explanation
| Flag | Purpose |
|------|---------|
| `--disable-all-custom-nodes` | Skip import of every custom node in `custom_nodes/`. Eliminates side effects from unrelated packs. |
| `--whitelist-custom-nodes comfyui-impact-pack comfyui-impact-subpack` | Re-enable only the listed folder names. Accepts multiple values separated by spaces. |
| `--port 18188` | Bind to a non-default port so the test server does not collide with a developer's regular ComfyUI instance on 8188. |
### Expected Startup Log Markers
After launch, the server log should include lines similar to:
```
### Loading: ComfyUI-Impact-Pack (V8.28.2)
### Loading: ComfyUI-Impact-Subpack (V<version>)
Skipping <other-custom-node> due to disable_all_custom_nodes and whitelist_custom_nodes
...
To see the GUI go to: http://127.0.0.1:18188
```
The `Skipping ...` lines confirm isolation: other custom nodes are present on disk but were not loaded. The final `To see the GUI ...` line confirms the server is ready to accept connections.
---
## Baseline Smoke Test
The smoke test confirms that an isolated server serves both the frontend page and the node catalog. It is intentionally small and has no external dependencies beyond Playwright.
### Script
```python
# e2e_smoke.py
# Usage: python e2e_smoke.py
# Preconditions:
# - ComfyUI is running at http://127.0.0.1:18188 with Impact Pack AND
# Impact Subpack whitelisted (default launch).
# - Playwright Python + chromium runtime are installed.
from playwright.sync_api import sync_playwright
BASE_URL = "http://127.0.0.1:18188"
REQUIRED_SUBPACK_NODES = {
"UltralyticsDetectorProvider",
"SAMLoader",
"SAMDetectorCombined",
"SAMDetectorSegmented",
}
def main() -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_context().new_page()
# 1. Frontend page loads
page.goto(f"{BASE_URL}/", wait_until="networkidle")
title = page.title()
assert "ComfyUI" in title, f"Unexpected page title: {title!r}"
# 2. /object_info returns the node catalog
resp = page.request.get(f"{BASE_URL}/object_info")
assert resp.status == 200, f"/object_info HTTP {resp.status}"
object_info = resp.json()
# 3. Impact Pack nodes are registered
impact_nodes = [name for name in object_info if name.startswith("Impact")]
assert len(impact_nodes) >= 85, (
f"Impact node count regression: got {len(impact_nodes)}, expected >= 85"
)
# 4. Impact Subpack nodes are registered
missing = REQUIRED_SUBPACK_NODES - set(object_info.keys())
assert not missing, f"Subpack nodes missing: {sorted(missing)}"
print(f"Title: {title}")
print(f"Total nodes: {len(object_info)}")
print(f"Impact nodes: {len(impact_nodes)}")
print(f"Sample: {impact_nodes[:3]}")
browser.close()
if __name__ == "__main__":
main()
```
### Expected Output
Observed baseline against Impact Pack v8.28.2 + Impact Subpack on an isolated server (default launch with both packs whitelisted):
```
Title: *Unsaved Workflow - ComfyUI
Total nodes: 858
Impact nodes: 87
Sample: ['ImpactSegsAndMask', 'ImpactSegsAndMaskForEach', 'ImpactFlattenMask']
```
Additional Subpack-contributed node names verified present: `UltralyticsDetectorProvider`, `SAMLoader`, `SAMDetectorCombined`, `SAMDetectorSegmented`.
Exact values will drift as the codebase evolves; the assertions above validate the minimum contract, not literal equality.
---
## Workflow Execution E2E
The baseline smoke test confirms node registration, but it does not exercise execution logic. Workflow execution E2E posts a concrete graph to `/prompt`, polls `/history/{prompt_id}` until completion, and downloads output artifacts from `/view` for inspection. This catches regressions in the inference path that are invisible to catalog-level checks.
### API Pattern
```
POST /prompt — submit {prompt, client_id}, receive {prompt_id}
GET /queue — running + pending prompts (progress monitoring)
GET /history/{prompt_id} — status + outputs once execution completes
GET /view?filename=... — download an individual output artifact (PNG, etc.)
```
Polling cadence of ~3s on `/history` is sufficient; the endpoint returns an empty object while the prompt is still in flight and populates fully on completion.
### Flat Prompt Format
The `/prompt` endpoint expects a **flat** graph: a dict keyed by node ID, where each value is `{class_type, inputs}`. Socket connections are expressed as two-element lists `[upstream_node_id, output_slot_index]`.
```python
PROMPT = {
"ckpt": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "SD1.5/realcartoonPixar_v8.safetensors"},
},
"pos": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": ["ckpt", 1], "text": "a detailed face, sharp focus"},
},
"img": {
"class_type": "LoadImage",
"inputs": {"image": "ComfyUI_00156_.png"},
},
"detector": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {"model_name": "bbox/face_yolov8m.pt"},
},
"detail": {
"class_type": "SEGSDetailer",
"inputs": {
"image": ["img", 0],
"segs": ["bbox_segs", 0],
# ... sampler knobs elided ...
"noise_mask_feather": 20, # non-zero triggers DifferentialDiffusion path
},
},
}
```
### Pitfall: Subgraph Blueprints
Workflow JSON exported from the ComfyUI UI may contain high-level blueprint nodes such as `workflow/Impact::MAKE_BASIC_PIPE`. These are template/subgraph references that the UI expands client-side; they are not valid `class_type` values for direct `/prompt` submission.
For programmatic E2E, **flatten blueprints into their concrete constituent nodes** before posting. For example, a `MAKE_BASIC_PIPE` blueprint flattens into:
```
CheckpointLoaderSimple → (model, clip, vae)
CLIPTextEncode (positive prompt)
CLIPTextEncode (negative prompt)
ToBasicPipe (model, clip, vae, positive, negative)
```
The reference implementation below demonstrates this flattening.
### Reference Implementation
`tests/e2e_dd_compat.py` is a validated reference workflow covering the critical detailer path:
```
LoadImage
→ UltralyticsDetectorProvider
→ BboxDetectorSEGS
→ SEGSDetailer(noise_mask_feather=20) # activates DifferentialDiffusion compat
→ SEGSPaste
→ PreviewImage (paste output)
→ PreviewImage (untouched input)
```
Pass criteria:
- Submission returns HTTP 200 with a `prompt_id`.
- `/history/{prompt_id}` eventually reports `status.status_str == "success"` with no `execution_error` messages.
- Both expected `PreviewImage` outputs are present in `history[prompt_id].outputs`.
- Input preview and paste preview have matching dimensions.
- Paste preview has non-degenerate statistics (`std >= 1.0`).
- Paste preview differs from input (`abs(mean_delta) >= 0.005` or `abs(std_delta) >= 0.005`) — equality would indicate the detailer path, and therefore the DifferentialDiffusion compat shim, was bypassed.
Typical observed deltas for the reference image: `mean_delta ≈ 0.01`, `std_delta ≈ 0.03`. These are small because `SEGSPaste` only rewrites the cropped face region; the majority of the frame is untouched and subtracts out.
---
## Verification Criteria
The smoke test is considered to PASS when **all** of the following hold:
| # | Criterion | How to verify |
|---|-----------|---------------|
| 1 | Server startup log contains `Loading: ComfyUI-Impact-Pack` | Inspect server stdout / log tail |
| 2 | Server startup log contains `Loading: ComfyUI-Impact-Subpack` (default launch) | Inspect server stdout / log tail |
| 3 | Log contains `Skipping ... due to disable_all_custom_nodes and whitelist_custom_nodes` for at least one other custom node (when other nodes are installed) | Inspect server log |
| 4 | `http://127.0.0.1:18188/` returns HTTP 200 and a page title containing `ComfyUI` | `page.goto(...)` + `page.title()` |
| 5 | `GET /object_info` returns HTTP 200 with a JSON body | `page.request.get(...).status` and `.json()` |
| 6 | `/object_info` contains at least 85 Impact-prefixed node names | Count keys where `name.startswith("Impact")` |
| 7 | Required Subpack nodes present: `UltralyticsDetectorProvider`, `SAMLoader`, `SAMDetectorCombined`, `SAMDetectorSegmented` | Set membership against `object_info` keys |
The baseline threshold of 85 was chosen below the observed value of 87 to tolerate minor refactors that rename or remove a handful of nodes without triggering a false failure. Raise the threshold deliberately when new nodes ship; lower it only with a reviewed explanation.
For workflow execution tests, pass criteria are scenario-specific; see the per-test criteria listed alongside each reference implementation.
---
## Operational Notes
### Cache Busting
ComfyUI caches sampler outputs across runs when node inputs are identical. This can mask regressions — a workflow may appear to "pass" because it is replaying a cached success.
| Strategy | When to use |
|----------|-------------|
| Per-run seed randomization (`seed = int(time.time()) & 0xFFFFFFFF`) | Default; cheapest invalidation for sampler-bearing nodes |
| Full server restart | After changing Python source inside `modules/impact/` or loaded packages |
| Clear `modules/impact/__pycache__/` | When `.pyc` files may be stale relative to edited `.py` files |
When restarting the server, kill any prior instance first and confirm the port is free before relaunching:
```bash
pkill -9 -f 'python main.py'
# wait a moment, then verify nothing is still listening on the test port
curl -fsS http://127.0.0.1:18188/system_stats && echo "still up" || echo "port free"
```
Only relaunch once the probe reports the port is free. Launching while a dying process still holds the socket produces confusing `address already in use` errors downstream.
### Verifying Internal Code Paths Executed
Workflow-level pass criteria (`status_str == "success"`, no exception, non-zero pixel delta) prove the graph ran end to end. They do **not** prove that a specific internal function was reached. A bug that silently bypasses a compat shim can still return `success`.
To confirm a specific branch executed, temporarily instrument the target:
```python
# modules/impact/utils.py (temporary)
import logging
def apply_differential_diffusion(...):
logging.warning("[E2E-MARKER] apply_differential_diffusion:execute")
...
```
Run the workflow, then grep the server log for the marker:
```bash
grep 'E2E-MARKER' /tmp/server.log
```
Absence of the marker despite `status_str == "success"` is a signal that the code path was skipped — typically because an upstream dispatch picked a different branch. Remove the marker before committing.
### Log File Decoding
Progress bars emitted by `tqdm` (used by samplers, detectors, SAM) write carriage returns (`\r`) rather than newlines, collapsing a long run onto a single physical line in the log file. Naive `grep` on that file may report only the final progress state.
Normalize before grepping:
```bash
tr '\r' '\n' < /tmp/server.log | grep -F '[E2E-MARKER]'
```
Or in Python:
```python
with open("/tmp/server.log", "r", errors="replace") as f:
text = f.read().replace("\r", "\n")
```
---
## Extension Points
The smoke and reference workflow tests are the baseline verification layers. Future E2E scenarios should build on the same isolated-launch foundation:
### Reference Test Implementation
`tests/e2e_dd_compat.py` — validated workflow covering LoadImage → UltralyticsDetectorProvider → BboxDetectorSEGS → SEGSDetailer → SEGSPaste → PreviewImage. Demonstrates the full `/prompt` + `/history` + `/view` lifecycle and the pixel-delta assertion pattern. Treat it as the canonical template for new workflow-execution tests.
### Additional Workflow Scenarios
Beyond the detailer compat path, useful workflow-level tests include: FaceDetailer end-to-end (KSampler-generated face pipe), SEGSDetailer with `cycle > 1` (iterative refinement), MASK_TO_SEGS + SEGSPaste (mask-driven editing), and Impact Switch / Pipe nodes (control-flow regressions).
### UI-Driven Node Creation
Use Playwright to open the frontend, drag an Impact node from the node library onto the canvas, and connect inputs/outputs. Validates that frontend metadata (category, display name, input schema) stays synchronized with backend definitions.
### Node Signature Regression Detection
Snapshot the full `/object_info` payload for a known-good release, then diff against the current response. Flag any change in input type, input name, output type, or output count. Useful as a pre-release guard against accidental public API breakage.
### Headed Mode for Debugging
For interactive debugging, launch Playwright with `headless=False` and optionally `slow_mo=500`. Pair with `page.pause()` at the point of failure to inspect the live browser state.
```python
browser = p.chromium.launch(headless=False, slow_mo=500)
# ... later ...
page.pause() # opens Playwright Inspector
```
### Cross-Browser Coverage
Extend beyond chromium by parameterizing the browser launcher over `p.chromium`, `p.firefox`, and `p.webkit`. Impact Pack's frontend surface is thin, but cross-browser validation guards against regressions introduced by future frontend-facing features.
+144
View File
@@ -135,3 +135,147 @@ function refreshPreview(event) {
}
api.addEventListener("impact-preview", refreshPreview);
// ============================================================================
// MaskRectArea Shared Utilities
// ============================================================================
/**
* Reads a numeric value from a connected link by inspecting the origin node widget.
* More reliable than getInputData() in ComfyUI's frontend execution model.
*
* @param {LGraphNode} node - LiteGraph node instance
* @param {string} inputName - Name of the input to read
* @returns {number|null} The numeric value or null if not available
*/
export function readLinkedNumber(node, inputName) {
try {
if (!node || !node.graph || !Array.isArray(node.inputs)) {
return null;
}
const inp = node.inputs.find(i => i && i.name === inputName);
if (!inp || inp.link == null) {
return null;
}
const link = node.graph.links && node.graph.links[inp.link];
if (!link) {
return null;
}
const originNode = node.graph.getNodeById
? node.graph.getNodeById(link.origin_id)
: null;
if (!originNode || !Array.isArray(originNode.widgets) || originNode.widgets.length === 0) {
return null;
}
const w = originNode.widgets.find(ww => ww && ww.name === "value")
|| originNode.widgets[0];
const v = w ? w.value : null;
return (typeof v === "number") ? v : null;
} catch (e) {
return null;
}
}
/**
* Generates a color based on percentage using HSL color space.
*
* @param {number} percent - Value between 0 and 1
* @param {string} alpha - Hex alpha value (e.g., "ff", "80")
* @returns {string} Hex color string with alpha (e.g., "#ff8040ff")
*/
export function getDrawColor(percent, alpha) {
let h = 360 * percent;
let s = 50;
let l = 50;
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = n => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}${alpha}`;
}
/**
* Computes and adjusts canvas size for preview widgets.
*
* @param {LGraphNode} node - LiteGraph node instance
* @param {[number, number]} size - [width, height] array
* @param {number} minHeight - Minimum canvas height (REQUIRED)
* @param {number} minWidth - Minimum canvas width (REQUIRED)
* @returns {void}
*/
export function computeCanvasSize(node, size, minHeight, minWidth) {
// Validate required parameters
if (typeof minHeight !== 'number' || typeof minWidth !== 'number') {
console.warn('[computeCanvasSize] minHeight and minWidth are required parameters');
return;
}
// Null safety check for widgets array
if (!node.widgets?.length || node.widgets[0].last_y == null) {
return;
}
// LiteGraph global availability check
const NODE_WIDGET_HEIGHT = (typeof LiteGraph !== 'undefined' && LiteGraph.NODE_WIDGET_HEIGHT)
? LiteGraph.NODE_WIDGET_HEIGHT
: 20;
let y = node.widgets[0].last_y + 5;
let freeSpace = size[1] - y;
// Compute the height of all non-customCanvas widgets
let widgetHeight = 0;
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
if (w.type !== "customCanvas") {
if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
} else {
widgetHeight += NODE_WIDGET_HEIGHT + 5;
}
}
}
// Ensure there is enough vertical space
freeSpace -= widgetHeight;
// Clamp minimum canvas height
if (freeSpace < minHeight) {
freeSpace = minHeight;
}
// Allow both grow and shrink to fit content
const targetHeight = y + widgetHeight + freeSpace;
if (node.size[1] !== targetHeight) {
node.size[1] = targetHeight;
node.graph.setDirtyCanvas(true);
}
// Ensure the node width meets the minimum width requirement
if (node.size[0] < minWidth) {
node.size[0] = minWidth;
node.graph.setDirtyCanvas(true);
}
// Position each of the widgets
for (const w of node.widgets) {
w.y = y;
if (w.type === "customCanvas") {
y += freeSpace;
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
} else {
y += NODE_WIDGET_HEIGHT + 4;
}
}
node.canvasHeight = freeSpace;
}
+176 -98
View File
@@ -1,4 +1,5 @@
import { app } from "../../scripts/app.js";
import { readLinkedNumber, getDrawColor, computeCanvasSize } from "./common.js";
function showPreviewCanvas(node, app) {
const widget = {
@@ -15,7 +16,7 @@ function showPreviewCanvas(node, app) {
// If we are initially offscreen when created we wont have received a resize event
// Calculate it here instead
if (!node.canvasHeight) {
computeCanvasSize(node, node.size);
computeCanvasSize(node, node.size, 220, 240);
}
const visible = true;
@@ -23,9 +24,16 @@ function showPreviewCanvas(node, app) {
const margin = 12;
const border = 2;
const widgetHeight = node.canvasHeight;
const width = Math.round(node.properties["width"]);
const height = Math.round(node.properties["height"]);
const scale = Math.min((widgetWidth - margin * 3) / width, (widgetHeight - margin * 3) / height);
// Keep preview in sync when inputs are driven by links.
syncLinkedInputsToPropertiesAdvanced(node);
const width = Math.max(1, Math.round(node.properties["width"]));
const height = Math.max(1, Math.round(node.properties["height"]));
const scale = Math.min(
(widgetWidth - margin * 3) / width,
(widgetHeight - margin * 3) / height
);
const blurRadius = node.properties["blur_radius"] || 0;
const index = 0;
@@ -120,11 +128,11 @@ function showPreviewCanvas(node, app) {
xOffset += (widgetWidth - backgroundWidth) / 2 - margin;
}
// Ajustar las coordenadas X e Y
// Adjust X and Y coordinates
const barHeight = 8;
let widgetYBar = widgetY + backgroundHeight + margin;
// Dibujar el borde negro alrededor de la barra
// Draw the border around the progress bar
ctx.fillStyle = globalThis.LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.fillRect(
widgetX - border,
@@ -133,8 +141,8 @@ function showPreviewCanvas(node, app) {
barHeight + border * 2
);
// Dibujar el área principal de la barra (fondo)
ctx.fillStyle = globalThis.LiteGraph.WIDGET_BGCOLOR; // Mismo color de fondo que el canvas
// Draw the main bar area (background)
ctx.fillStyle = globalThis.LiteGraph.WIDGET_BGCOLOR;
ctx.fillRect(
widgetX,
widgetYBar,
@@ -142,16 +150,15 @@ function showPreviewCanvas(node, app) {
barHeight
);
// Draw progress bar grid
ctx.beginPath();
ctx.lineWidth = 1;
ctx.strokeStyle = "#66666650";
// Calcular el número de líneas en función del tamaño de la barra
// Calculate the number of grid lines based on the bar size
const numLines = Math.floor(backgroundWidth / 64);
// Dibujar líneas del grid
// Draw grid lines
for (let x = 0; x <= width / 64; x += 1) {
ctx.moveTo(widgetX + x * 64 * scale, widgetYBar);
ctx.lineTo(widgetX + x * 64 * scale, widgetYBar + barHeight);
@@ -159,7 +166,7 @@ function showPreviewCanvas(node, app) {
ctx.stroke();
ctx.closePath();
// Dibujar progreso (basado en blur_radius)
// Draw progress (based on blur_radius)
const progress = Math.min(blurRadius / 255, 1);
ctx.fillStyle = "rgba(0, 120, 255, 0.5)";
@@ -176,6 +183,13 @@ function showPreviewCanvas(node, app) {
widget.canvas.className = "mask-rect-area-canvas";
widget.parent = node;
widget.computeLayoutSize = function (node) {
return {
minHeight: 200,
maxHeight: 300
};
};
document.body.appendChild(widget.canvas);
node.addCustomWidget(widget);
@@ -196,32 +210,92 @@ function showPreviewCanvas(node, app) {
};
node.onResize = function (size) {
computeCanvasSize(node, size);
computeCanvasSize(node, size, 220, 240);
};
return {minWidth: 200, minHeight: 200, widget};
}
app.registerExtension({
name: 'drltdata.MaskRectAreaAdvanced',
name: "drltdata.MaskRectAreaAdvanced",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "MaskRectAreaAdvanced") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
if (nodeData.name !== "MaskRectAreaAdvanced") {
return;
}
this.setProperty("width", 512);
this.setProperty("height", 512);
this.setProperty("x", 0);
this.setProperty("y", 0);
this.setProperty("w", 256);
this.setProperty("h", 256);
this.setProperty("blur_radius", 0);
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
this.selected = false;
this.index = 3;
this.serialize_widgets = true;
this.setProperty("width", 512);
this.setProperty("height", 512);
this.setProperty("x", 0);
this.setProperty("y", 0);
this.setProperty("w", 256);
this.setProperty("h", 256);
this.setProperty("blur_radius", 0);
this.selected = false;
this.index = 3;
this.serialize_widgets = true;
// If the node already provides widgets from Python/ComfyUI, do NOT recreate them
const hasExisting = Array.isArray(this.widgets) && this.widgets.some(w => w && w.name === "x");
// Helper: attach callbacks to existing widgets to keep node.properties in sync (canvas preview).
const hookWidget = (node, widgetName, propName, opts) => {
if (!Array.isArray(node.widgets)) {
return;
}
const w = node.widgets.find(ww => ww && ww.name === widgetName);
if (!w) {
return;
}
const min = (opts && typeof opts.min === "number") ? opts.min : undefined;
const max = (opts && typeof opts.max === "number") ? opts.max : undefined;
const step = (opts && typeof opts.step === "number") ? opts.step : undefined;
if (node.properties && Object.prototype.hasOwnProperty.call(node.properties, propName)) {
w.value = node.properties[propName];
} else {
node.properties[propName] = w.value;
}
const prevCb = w.callback;
w.callback = function (v, ...args) {
let val = v;
if (typeof val === "number") {
if (typeof step === "number" && step > 0) {
const s = step / 10;
val = Math.round(val / s) * s;
} else {
val = Math.round(val);
}
if (typeof min === "number") {
val = Math.max(min, val);
}
if (typeof max === "number") {
val = Math.min(max, val);
}
}
this.value = val;
node.properties[propName] = val;
if (prevCb) {
return prevCb.call(this, val, ...args);
}
};
};
if (hasExisting) {
hookWidget(this, "x", "x", {"step": 10});
hookWidget(this, "y", "y", {"step": 10});
hookWidget(this, "width", "w", {"step": 10});
hookWidget(this, "height", "h", {"step": 10});
hookWidget(this, "image_width", "width", {"step": 10});
hookWidget(this, "image_height", "height", {"step": 10});
hookWidget(this, "blur_radius", "blur_radius", {"min": 0, "max": 255, "step": 10});
} else {
CUSTOM_INT(this, "x", 0, function (v, _, node) {
const s = this.options.step / 10;
this.value = Math.round(v / s) * s;
@@ -258,19 +332,19 @@ app.registerExtension({
},
{"min": 0, "max": 255, "step": 10}
);
}
showPreviewCanvas(this, app);
showPreviewCanvas(this, app);
this.onSelected = function () {
this.selected = true;
};
this.onDeselected = function () {
this.selected = false;
};
return r;
this.onSelected = function () {
this.selected = true;
};
}
this.onDeselected = function () {
this.selected = false;
};
return r;
};
}
});
@@ -311,71 +385,75 @@ function CUSTOM_INT(node, inputName, val, func, config = {}) {
};
}
function getDrawColor(percent, alpha) {
let h = 360 * percent;
let s = 50;
let l = 50;
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = n => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0'); // convert to Hex and prefix "0" if needed
};
return `#${f(0)}${f(8)}${f(4)}${alpha}`;
}
function syncLinkedInputsToPropertiesAdvanced(node) {
let changed = false;
function computeCanvasSize(node, size) {
if (node.widgets[0].last_y == null) {
return;
}
const MIN_HEIGHT = 220;
const MIN_WIDTH = 240;
let y = LiteGraph.NODE_WIDGET_HEIGHT * Math.max(node.inputs.length, node.outputs.length) + 5;
let freeSpace = size[1] - y;
// Compute the height of all non-customCanvas widgets
let widgetHeight = 0;
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
if (w.type !== "customCanvas") {
if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 5;
}
const vx = readLinkedNumber(node, "x");
if (vx != null) {
const nv = Math.max(0, Math.round(vx));
if (node.properties["x"] !== nv) {
node.properties["x"] = nv;
changed = true;
}
}
// Ensure there is enough vertical space
freeSpace -= widgetHeight;
// Adjust the height of the node if needed
if (freeSpace < MIN_HEIGHT) {
freeSpace = MIN_HEIGHT;
node.size[1] = y + widgetHeight + freeSpace;
node.graph.setDirtyCanvas(true);
}
// Ensure the node width meets the minimum width requirement
if (node.size[0] < MIN_WIDTH) {
node.size[0] = MIN_WIDTH;
node.graph.setDirtyCanvas(true);
}
// Position each of the widgets
for (const w of node.widgets) {
w.y = y;
if (w.type === "customCanvas") {
y += freeSpace;
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
const vy = readLinkedNumber(node, "y");
if (vy != null) {
const nv = Math.max(0, Math.round(vy));
if (node.properties["y"] !== nv) {
node.properties["y"] = nv;
changed = true;
}
}
node.canvasHeight = freeSpace;
// Input "width" is the rectangle width in px -> property "w"
const vw = readLinkedNumber(node, "width");
if (vw != null) {
const nv = Math.max(0, Math.round(vw));
if (node.properties["w"] !== nv) {
node.properties["w"] = nv;
changed = true;
}
}
// Input "height" is the rectangle height in px -> property "h"
const vh = readLinkedNumber(node, "height");
if (vh != null) {
const nv = Math.max(0, Math.round(vh));
if (node.properties["h"] !== nv) {
node.properties["h"] = nv;
changed = true;
}
}
// Image size (must be >=1 to avoid division by zero in getDrawArea)
const viw = readLinkedNumber(node, "image_width");
if (viw != null) {
const nv = Math.max(1, Math.round(viw));
if (node.properties["width"] !== nv) {
node.properties["width"] = nv;
changed = true;
}
}
const vih = readLinkedNumber(node, "image_height");
if (vih != null) {
const nv = Math.max(1, Math.round(vih));
if (node.properties["height"] !== nv) {
node.properties["height"] = nv;
changed = true;
}
}
const vbr = readLinkedNumber(node, "blur_radius");
if (vbr != null) {
const nv = Math.max(0, Math.min(255, Math.round(vbr)));
if (node.properties["blur_radius"] !== nv) {
node.properties["blur_radius"] = nv;
changed = true;
}
}
return changed;
}
+217 -89
View File
@@ -1,4 +1,5 @@
import { app } from "../../scripts/app.js";
import { readLinkedNumber, getDrawColor, computeCanvasSize } from "./common.js";
function showPreviewCanvas(node, app) {
const widget = {
@@ -15,7 +16,7 @@ function showPreviewCanvas(node, app) {
// If we are initially offscreen when created we wont have received a resize event
// Calculate it here instead
if (!node.canvasHeight) {
computeCanvasSize(node, node.size);
computeCanvasSize(node, node.size, 200, 200);
}
const visible = true;
@@ -64,6 +65,9 @@ function showPreviewCanvas(node, app) {
ctx.fillStyle = globalThis.LiteGraph.WIDGET_BGCOLOR;
ctx.fillRect(widgetX, widgetY, backgroundWidth, backgroundHeight);
// Keep preview in sync when inputs are driven by links.
syncLinkedInputsToProperties(node);
// Draw the conditioning zone
let [x, y, w, h] = getDrawArea(node, backgroundWidth, backgroundHeight);
@@ -100,7 +104,6 @@ function showPreviewCanvas(node, app) {
ctx.strokeStyle = globalThis.LiteGraph.NODE_SELECTED_TITLE_COLOR;
ctx.lineWidth = 2;
ctx.strokeRect(widgetX + sx, widgetY + sy, sw, sh);
//ctx.strokeRect(finalSX, finalSY, finalSW, finalSH);
// Display
ctx.beginPath();
@@ -173,6 +176,13 @@ function showPreviewCanvas(node, app) {
widget.canvas.className = "mask-rect-area-canvas";
widget.parent = node;
widget.computeLayoutSize = function (node) {
return {
minHeight: 200,
maxHeight: 300
};
};
document.body.appendChild(widget.canvas);
node.addCustomWidget(widget);
@@ -193,7 +203,7 @@ function showPreviewCanvas(node, app) {
};
node.onResize = function (size) {
computeCanvasSize(node, size);
computeCanvasSize(node, size, 200, 200);
};
return {minWidth: 200, minHeight: 200, widget};
@@ -202,25 +212,82 @@ function showPreviewCanvas(node, app) {
app.registerExtension({
name: 'drltdata.MaskRectArea',
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "MaskRectArea") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
if (nodeData.name !== "MaskRectArea") {
return;
}
this.setProperty("width", 512);
this.setProperty("height", 512);
this.setProperty("x", 0);
this.setProperty("y", 0);
this.setProperty("w", 50);
this.setProperty("h", 50);
this.setProperty("blur_radius", 0);
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
this.selected = false;
this.index = 3;
this.serialize_widgets = true;
this.setProperty("width", 512);
this.setProperty("height", 512);
this.setProperty("x", 0);
this.setProperty("y", 0);
this.setProperty("w", 50);
this.setProperty("h", 50);
this.setProperty("blur_radius", 0);
this.selected = false;
this.index = 3;
this.serialize_widgets = true;
// If Python/ComfyUI already created typed widgets, do not recreate them (avoid duplicates).
const hasExisting = Array.isArray(this.widgets) && this.widgets.some(w => w && w.name === "x");
// Hook existing widgets to keep node.properties in sync (canvas uses properties).
const hookWidget = (node, widgetName, propName, opts) => {
if (!Array.isArray(node.widgets)) {
return;
}
const w = node.widgets.find(ww => ww && ww.name === widgetName);
if (!w) {
return;
}
const min = (opts && typeof opts.min === "number") ? opts.min : undefined;
const max = (opts && typeof opts.max === "number") ? opts.max : undefined;
if (node.properties && Object.prototype.hasOwnProperty.call(node.properties, propName)) {
w.value = node.properties[propName];
} else {
node.properties[propName] = w.value;
}
const prevCb = w.callback;
w.callback = function (v, ...args) {
let val = v;
if (typeof val === "number") {
val = Math.round(val);
if (typeof min === "number") {
val = Math.max(min, val);
}
if (typeof max === "number") {
val = Math.min(max, val);
}
}
this.value = val;
node.properties[propName] = val;
if (prevCb) {
return prevCb.call(this, val, ...args);
}
};
};
if (hasExisting) {
// Note: "width"/"height" widgets map to "w"/"h" properties (percent-based).
hookWidget(this, "x", "x", {"min": 0, "max": 100});
hookWidget(this, "y", "y", {"min": 0, "max": 100});
hookWidget(this, "width", "w", {"min": 0, "max": 100});
hookWidget(this, "height", "h", {"min": 0, "max": 100});
hookWidget(this, "blur_radius", "blur_radius", {"min": 0, "max": 255});
} else {
CUSTOM_INT(this, "x", 0, function (v, _, node) {
this.value = Math.max(0, Math.min(100, Math.round(v))); // Limitar entre 0 y 100
this.value = Math.max(0, Math.min(100, Math.round(v)));
node.properties["x"] = this.value;
});
CUSTOM_INT(this, "y", 0, function (v, _, node) {
@@ -238,25 +305,104 @@ app.registerExtension({
CUSTOM_INT(this, "blur_radius", 0, function (v, _, node) {
this.value = Math.round(v) || 0;
node.properties["blur_radius"] = this.value;
},
{"min": 0, "max": 255, "step": 10}
);
}, {"min": 0, "max": 255, "step": 10});
showPreviewCanvas(this, app);
// If Python widgets exist, they will be used instead; this is back-compat only.
}
this.onSelected = function () {
this.selected = true;
};
this.onDeselected = function () {
this.selected = false;
showPreviewCanvas(this, app);
// Sync linked input values -> node.properties so the preview updates when driven by connections.
const prevOnExecute = this.onExecute;
this.onExecute = function () {
const rr = prevOnExecute ? prevOnExecute.apply(this, arguments) : undefined;
const readLinkedInt = (inputName) => {
if (!Array.isArray(this.inputs)) {
return null;
}
const inp = this.inputs.find(i => i && i.name === inputName);
if (!inp || !inp.link) {
return null;
}
try {
const v = this.getInputData(inputName);
return (typeof v === "number") ? v : null;
} catch (e) {
return null;
}
};
return r;
let changed = false;
const vx = readLinkedInt("x");
if (vx != null) {
const nv = Math.max(0, Math.min(100, Math.round(vx)));
if (this.properties["x"] !== nv) {
this.properties["x"] = nv;
changed = true;
}
}
const vy = readLinkedInt("y");
if (vy != null) {
const nv = Math.max(0, Math.min(100, Math.round(vy)));
if (this.properties["y"] !== nv) {
this.properties["y"] = nv;
changed = true;
}
}
const vw = readLinkedInt("width");
if (vw != null) {
const nv = Math.max(0, Math.min(100, Math.round(vw)));
if (this.properties["w"] !== nv) {
this.properties["w"] = nv;
changed = true;
}
}
const vh = readLinkedInt("height");
if (vh != null) {
const nv = Math.max(0, Math.min(100, Math.round(vh)));
if (this.properties["h"] !== nv) {
this.properties["h"] = nv;
changed = true;
}
}
const vbr = readLinkedInt("blur_radius");
if (vbr != null) {
const nv = Math.max(0, Math.min(255, Math.round(vbr)));
if (this.properties["blur_radius"] !== nv) {
this.properties["blur_radius"] = nv;
changed = true;
}
}
if (changed) {
this.setDirtyCanvas(true, true);
if (this.graph) {
this.graph.setDirtyCanvas(true, true);
}
}
return rr;
};
}
this.onSelected = function () {
this.selected = true;
};
this.onDeselected = function () {
this.selected = false;
};
return r;
};
}
});
// Calculate the drawing area using percentage-based properties.
function getDrawArea(node, backgroundWidth, backgroundHeight) {
// Convert percentages to actual pixel values based on the background dimensions
@@ -296,71 +442,53 @@ function CUSTOM_INT(node, inputName, val, func, config = {}) {
};
}
function getDrawColor(percent, alpha) {
let h = 360 * percent;
let s = 50;
let l = 50;
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = n => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0'); // convert to Hex and prefix "0" if needed
};
return `#${f(0)}${f(8)}${f(4)}${alpha}`;
}
function syncLinkedInputsToProperties(node) {
let changed = false;
function computeCanvasSize(node, size) {
if (node.widgets[0].last_y == null) {
return;
}
const MIN_HEIGHT = 200;
const MIN_WIDTH = 200;
let y = LiteGraph.NODE_WIDGET_HEIGHT * Math.max(node.inputs.length, node.outputs.length) + 5;
let freeSpace = size[1] - y;
// Compute the height of all non-customCanvas widgets
let widgetHeight = 0;
for (let i = 0; i < node.widgets.length; i++) {
const w = node.widgets[i];
if (w.type !== "customCanvas") {
if (w.computeSize) {
widgetHeight += w.computeSize()[1] + 4;
} else {
widgetHeight += LiteGraph.NODE_WIDGET_HEIGHT + 5;
}
const vx = readLinkedNumber(node, "x");
if (vx != null) {
const nv = Math.max(0, Math.min(100, Math.round(vx)));
if (node.properties["x"] !== nv) {
node.properties["x"] = nv;
changed = true;
}
}
// Ensure there is enough vertical space
freeSpace -= widgetHeight;
// Adjust the height of the node if needed
if (freeSpace < MIN_HEIGHT) {
freeSpace = MIN_HEIGHT;
node.size[1] = y + widgetHeight + freeSpace;
node.graph.setDirtyCanvas(true);
}
// Ensure the node width meets the minimum width requirement
if (node.size[0] < MIN_WIDTH) {
node.size[0] = MIN_WIDTH;
node.graph.setDirtyCanvas(true);
}
// Position each of the widgets
for (const w of node.widgets) {
w.y = y;
if (w.type === "customCanvas") {
y += freeSpace;
} else if (w.computeSize) {
y += w.computeSize()[1] + 4;
} else {
y += LiteGraph.NODE_WIDGET_HEIGHT + 4;
const vy = readLinkedNumber(node, "y");
if (vy != null) {
const nv = Math.max(0, Math.min(100, Math.round(vy)));
if (node.properties["y"] !== nv) {
node.properties["y"] = nv;
changed = true;
}
}
node.canvasHeight = freeSpace;
const vw = readLinkedNumber(node, "width");
if (vw != null) {
const nv = Math.max(0, Math.min(100, Math.round(vw)));
if (node.properties["w"] !== nv) {
node.properties["w"] = nv;
changed = true;
}
}
const vh = readLinkedNumber(node, "height");
if (vh != null) {
const nv = Math.max(0, Math.min(100, Math.round(vh)));
if (node.properties["h"] !== nv) {
node.properties["h"] = nv;
changed = true;
}
}
const vbr = readLinkedNumber(node, "blur_radius");
if (vbr != null) {
const nv = Math.max(0, Math.min(255, Math.round(vbr)));
if (node.properties["blur_radius"] !== nv) {
node.properties["blur_radius"] = nv;
changed = true;
}
}
return changed;
}
+1 -1
View File
@@ -66,7 +66,7 @@ class SEGSDetailerForAnimateDiff:
cnet_image_list = []
if not (isinstance(model, str) and model == "DUMMY") and noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
model = nodes_differential_diffusion.DifferentialDiffusion().execute(model)[0]
model = utils.apply_differential_diffusion(model)
for seg in segs[1]:
cropped_image_frames = None
+1 -1
View File
@@ -2,7 +2,7 @@ import configparser
import logging
import os
version_code = [8, 28]
version_code = [8, 28, 3]
version = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
my_path = os.path.dirname(__file__)
+2 -2
View File
@@ -262,7 +262,7 @@ def enhance_detail(image, model, clip, vae, guide_size, guide_size_for_bbox, max
noise_mask = noise_mask.squeeze(3)
if noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
model = nodes_differential_diffusion.DifferentialDiffusion().execute(model)[0]
model = utils.apply_differential_diffusion(model)
if wildcard_opt is not None and wildcard_opt != "":
model, _, wildcard_positive = wildcards.process_with_loras(wildcard_opt, model, clip)
@@ -435,7 +435,7 @@ def enhance_detail_for_animatediff(image_frames, model, clip, vae, guide_size, g
noise_mask = noise_mask.squeeze(3)
if noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
model = nodes_differential_diffusion.DifferentialDiffusion().execute(model)[0]
model = utils.apply_differential_diffusion(model)
if wildcard_opt is not None and wildcard_opt != "":
model, _, wildcard_positive = wildcards.process_with_loras(wildcard_opt, model, clip)
+103 -52
View File
@@ -301,7 +301,7 @@ class DetailerForEach:
ordered_segs = segs[1]
if not (isinstance(model, str) and model == "DUMMY") and noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
model = nodes_differential_diffusion.DifferentialDiffusion().execute(model)[0]
model = utils.apply_differential_diffusion(model)
for i, seg in enumerate(ordered_segs):
cropped_image = utils.crop_ndarray4(image.cpu().numpy(), seg.crop_region) # Never use seg.cropped_image to handle overlapping area
@@ -518,7 +518,7 @@ class DetailerForEachAutoRetry:
ordered_segs = segs[1]
if not (isinstance(model, str) and model == "DUMMY") and noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
model = nodes_differential_diffusion.DifferentialDiffusion().execute(model)[0]
model = utils.apply_differential_diffusion(model)
for i, seg in enumerate(ordered_segs):
cropped_image = utils.crop_ndarray4(image.cpu().numpy(), seg.crop_region) # Never use seg.cropped_image to handle overlapping area
@@ -2157,6 +2157,12 @@ class MaskRectArea:
def INPUT_TYPES(cls):
return {
"required": {
# Added typed INT inputs so this node can be driven by other INT nodes.
"x": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
"y": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}),
"width": ("INT", {"default": 50, "min": 0, "max": 100, "step": 1}),
"height": ("INT", {"default": 50, "min": 0, "max": 100, "step": 1}),
"blur_radius": ("INT", {"default": 0, "min": 0, "step": 1})
},
"hidden": {"extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}
}
@@ -2166,34 +2172,68 @@ class MaskRectArea:
CATEGORY = "ImpactPack/Operation"
FUNCTION = "create_mask"
def create_mask(self, extra_pnginfo, unique_id, **kwargs):
# search for node
node_found = False
for node in extra_pnginfo["workflow"]["nodes"]:
if str(node["id"]) == unique_id:
min_x = node["properties"].get("x", 0) / 100
min_y = node["properties"].get("y", 0) / 100
width = node["properties"].get("w", 0) / 100
height = node["properties"].get("h", 0) / 100
blur_radius = node["properties"].get("blur_radius", 0)
node_found = True
break
def create_mask(self, x, y, width, height, blur_radius, extra_pnginfo, unique_id):
# Backward-compat: if node properties exist in workflow, prefer them.
try:
for node in extra_pnginfo["workflow"]["nodes"]:
if str(node["id"]) == str(unique_id):
props = node.get("properties", {})
x = int(props.get("x", x))
y = int(props.get("y", y))
width = int(props.get("w", width))
height = int(props.get("h", height))
blur_radius = int(props.get("blur_radius", blur_radius))
break
except Exception:
pass
if not node_found:
raise ValueError(f"No node found with unique_id {unique_id}.")
# Clamp percent inputs
if x < 0:
x = 0
if y < 0:
y = 0
if width < 0:
width = 0
if height < 0:
height = 0
if x > 100:
x = 100
if y > 100:
y = 100
if width > 100:
width = 100
if height > 100:
height = 100
# Convert percent to ratio
min_x = x / 100.0
min_y = y / 100.0
w_ratio = width / 100.0
h_ratio = height / 100.0
# Create a mask with standard resolution (e.g., 512x512)
resolution = 512
mask = torch.zeros((resolution, resolution))
mask = torch.zeros((resolution, resolution), dtype=torch.float32)
# Calculate pixel coordinates
min_x_px = int(min_x * resolution)
min_y_px = int(min_y * resolution)
max_x_px = int((min_x + width) * resolution)
max_y_px = int((min_y + height) * resolution)
max_x_px = int((min_x + w_ratio) * resolution)
max_y_px = int((min_y + h_ratio) * resolution)
# Clamp pixel bounds
if min_x_px < 0:
min_x_px = 0
if min_y_px < 0:
min_y_px = 0
if max_x_px > resolution:
max_x_px = resolution
if max_y_px > resolution:
max_y_px = resolution
# Draw the rectangle on the mask
mask[min_y_px:max_y_px, min_x_px:max_x_px] = 1
if max_x_px > min_x_px and max_y_px > min_y_px:
mask[min_y_px:max_y_px, min_x_px:max_x_px] = 1.0
# Apply blur if the radii are greater than 0
if blur_radius > 0:
@@ -2222,6 +2262,13 @@ class MaskRectAreaAdvanced:
def INPUT_TYPES(cls):
return {
"required": {
"x": ("INT", {"default": 0, "min": 0, "step": 1}),
"y": ("INT", {"default": 0, "min": 0, "step": 1}),
"width": ("INT", {"default": 256, "min": 0, "step": 1}),
"height": ("INT", {"default": 320, "min": 0, "step": 1}),
"image_width": ("INT", {"default": 512, "min": 1, "step": 1}),
"image_height": ("INT", {"default": 320, "min": 1, "step": 1}),
"blur_radius": ("INT", {"default": 0, "min": 0, "step": 1})
},
"hidden": {"extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}
}
@@ -2231,46 +2278,50 @@ class MaskRectAreaAdvanced:
CATEGORY = "ImpactPack/Operation"
FUNCTION = "create_mask_advanced"
def create_mask_advanced(self, extra_pnginfo, unique_id, **kwargs):
# search for node
node_found = False
for node in extra_pnginfo["workflow"]["nodes"]:
if node["id"] == int(unique_id):
min_x = node["properties"]["x"]
min_y = node["properties"]["y"]
width = node["properties"]["w"]
height = node["properties"]["h"]
image_width = node["properties"]["width"]
image_height = node["properties"]["height"]
blur_radius = node["properties"]["blur_radius"]
node_found = True
break
def create_mask_advanced(self, x, y, width, height, image_width, image_height, blur_radius, extra_pnginfo, unique_id):
# Backward-compat fallback: if node properties exist in workflow, prefer them
try:
for node in extra_pnginfo["workflow"]["nodes"]:
if node["id"] == int(unique_id):
props = node.get("properties", {})
x = int(props.get("x", x))
y = int(props.get("y", y))
width = int(props.get("w", width))
height = int(props.get("h", height))
image_width = int(props.get("width", image_width))
image_height = int(props.get("height", image_height))
blur_radius = int(props.get("blur_radius", blur_radius))
break
except Exception:
pass
if not node_found:
raise ValueError(f"No node found with unique_id {unique_id}.")
# Clamp to safe bounds
if image_width < 1:
image_width = 1
if image_height < 1:
image_height = 1
if width < 0:
width = 0
if height < 0:
height = 0
if x < 0:
x = 0
if y < 0:
y = 0
# Calculate maximum coordinates
max_x = min_x + width
max_y = min_y + height
max_x = min(x + width, image_width)
max_y = min(y + height, image_height)
# Create a mask with the image dimensions
mask = torch.zeros((image_height, image_width))
mask = torch.zeros((image_height, image_width), dtype=torch.float32)
# Draw the rectangle on the mask
mask[int(min_y):int(max_y), int(min_x):int(max_x)] = 1
if max_x > x and max_y > y:
mask[y:max_y, x:max_x] = 1.0
# Apply blur if the radii are greater than 0
if blur_radius > 0:
dx = blur_radius * 2 + 1
dy = blur_radius * 2 + 1
# Convert the mask to a format compatible with OpenCV (numpy array)
k = blur_radius * 2 + 1
mask_np = mask.cpu().numpy().astype("float32")
# Apply Gaussian Blur
blurred_mask = cv2.GaussianBlur(mask_np, (dx, dy), 0)
# Convert back to tensor
blurred_mask = cv2.GaussianBlur(mask_np, (k, k), 0)
mask = torch.from_numpy(blurred_mask)
# Return the mask as a tensor with an additional channel
+1 -1
View File
@@ -86,7 +86,7 @@ class SEGSDetailer:
cnet_pil_list = []
if not (isinstance(model, str) and model == "DUMMY") and noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
model = nodes_differential_diffusion.DifferentialDiffusion().execute(model)[0]
model = utils.apply_differential_diffusion(model)
for i in range(batch_size):
seed += 1
+1 -1
View File
@@ -102,7 +102,7 @@ def img2img_segs(image, model, clip, vae, seed, steps, cfg, sampler_name, schedu
noise_mask = noise_mask.squeeze(3)
if noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
model = nodes_differential_diffusion.DifferentialDiffusion().execute(model)[0]
model = utils.apply_differential_diffusion(model)
if control_net_wrapper is not None:
positive, negative, _ = control_net_wrapper.apply(positive, negative, image, noise_mask)
+16
View File
@@ -690,6 +690,22 @@ def try_install_custom_node(custom_node_url, msg):
logging.info("[Impact Pack] ComfyUI-Manager is outdated. The custom node installation feature is not available.")
def apply_differential_diffusion(model):
# ComfyUI ≥0.3.63 exposes V3 schema (classmethod `execute`); older versions use instance method `apply`.
# Import is deferred so callers with guarded imports (e.g. segs_upscaler.py) still work when the
# comfy_extras module is absent on very old ComfyUI — the ImportError propagates as before.
from comfy_extras import nodes_differential_diffusion
dd = nodes_differential_diffusion.DifferentialDiffusion()
if hasattr(dd, 'execute'):
return dd.execute(model)[0]
if hasattr(dd, 'apply'):
return dd.apply(model)[0]
raise AttributeError(
"DifferentialDiffusion has neither 'execute' nor 'apply'. "
"Update ComfyUI (≥0.3.63 for V3) or reinstall Impact Pack."
)
# author: Trung0246 --->
class TautologyStr(str):
def __ne__(self, other):
+13 -2
View File
@@ -1,9 +1,20 @@
[project]
name = "comfyui-impact-pack"
description = "This node pack offers various detector nodes and detailer nodes that allow you to configure a workflow that automatically enhances facial details. And provide iterative upscaler."
version = "8.28"
version = "8.28.3"
license = { file = "LICENSE.txt" }
dependencies = ["segment-anything", "scikit-image", "piexif", "transformers", "opencv-python-headless", "GitPython", "scipy>=1.11.4"]
dependencies = [
"segment-anything",
"scikit-image",
"piexif",
"transformers",
"opencv-python-headless",
"scipy",
"numpy",
"dill",
"matplotlib",
"sam2 @ git+https://github.com/facebookresearch/sam2"
]
[project.urls]
Repository = "https://github.com/ltdrdata/ComfyUI-Impact-Pack"
+260
View File
@@ -0,0 +1,260 @@
"""E2E test for the DifferentialDiffusion cross-version compat shim.
Exercises the `utils.apply_differential_diffusion` helper end-to-end through
a real SEGSDetailer inference with `noise_mask_feather > 0`. This is the
smallest graph that deterministically triggers the helper without relying on
FaceDetailer's KSampler-generated face pipeline.
Prerequisites:
- ComfyUI running on http://127.0.0.1:18188 with impact-pack AND
impact-subpack whitelisted:
python main.py --disable-all-custom-nodes \
--whitelist-custom-nodes comfyui-impact-pack comfyui-impact-subpack \
--port 18188
- Models available:
models/checkpoints/SD1.5/realcartoonPixar_v8.safetensors
models/ultralytics/bbox/face_yolov8m.pt
- Input image with a visible face at input/ComfyUI_00156_.png
- Python Playwright 1.58+ (`pip install playwright && playwright install chromium`)
What it verifies:
1. SEGSDetailer inference runs without AttributeError on DifferentialDiffusion.
2. Output image differs slightly from input (detailer actually edited the face).
3. Execution returns status=success.
"""
from playwright.sync_api import sync_playwright
import json
import time
import sys
import urllib.parse
BASE_URL = "http://127.0.0.1:18188"
TIMEOUT_S = 600
_SEED = int(time.time()) & 0xFFFFFFFF
PROMPT = {
"ckpt": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "SD1.5/realcartoonPixar_v8.safetensors"},
},
"pos": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": ["ckpt", 1], "text": "a detailed face, high quality, sharp focus"},
},
"neg": {
"class_type": "CLIPTextEncode",
"inputs": {"clip": ["ckpt", 1], "text": "blurry, low quality"},
},
"pipe": {
"class_type": "ToBasicPipe",
"inputs": {
"model": ["ckpt", 0],
"clip": ["ckpt", 1],
"vae": ["ckpt", 2],
"positive": ["pos", 0],
"negative": ["neg", 0],
},
},
"img": {
"class_type": "LoadImage",
"inputs": {"image": "ComfyUI_00156_.png"},
},
"detector": {
"class_type": "UltralyticsDetectorProvider",
"inputs": {"model_name": "bbox/face_yolov8m.pt"},
},
"bbox_segs": {
"class_type": "BboxDetectorSEGS",
"inputs": {
"bbox_detector": ["detector", 0],
"image": ["img", 0],
"threshold": 0.30,
"dilation": 10,
"crop_factor": 3.0,
"drop_size": 10,
"labels": "all",
},
},
# Non-zero noise_mask_feather is the critical knob — this is what
# activates the DifferentialDiffusion path inside enhance_detail and
# SEGSDetailer.do_detail.
"detail": {
"class_type": "SEGSDetailer",
"inputs": {
"image": ["img", 0],
"segs": ["bbox_segs", 0],
"guide_size": 512,
"guide_size_for": True,
"max_size": 1024,
"seed": _SEED,
"steps": 10,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 0.5,
"noise_mask": True,
"force_inpaint": True,
"basic_pipe": ["pipe", 0],
"refiner_ratio": 0.2,
"batch_size": 1,
"cycle": 1,
"noise_mask_feather": 20,
},
},
"paste": {
"class_type": "SEGSPaste",
"inputs": {
"image": ["img", 0],
"segs": ["detail", 0],
"feather": 5,
"alpha": 255,
},
},
"preview_paste": {
"class_type": "PreviewImage",
"inputs": {"images": ["paste", 0]},
},
"preview_input": {
"class_type": "PreviewImage",
"inputs": {"images": ["img", 0]},
},
}
def fail(msg: str, code: int = 1) -> None:
print(f"FAIL: {msg}")
sys.exit(code)
def main() -> None:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
page = ctx.new_page()
page.goto(f"{BASE_URL}/", wait_until="domcontentloaded", timeout=30000)
submit = page.evaluate(
"""async (prompt) => {
const client_id = (window.api && window.api.clientId) || crypto.randomUUID();
const resp = await fetch('/prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, client_id })
});
const text = await resp.text();
let parsed = null; try { parsed = JSON.parse(text); } catch {}
return { status: resp.status, body: parsed || text };
}""",
PROMPT,
)
if submit.get("status") != 200 or not isinstance(submit.get("body"), dict):
fail(f"submission failed: {submit}", 2)
prompt_id = submit["body"].get("prompt_id")
if not prompt_id:
fail("no prompt_id returned", 2)
print(f"prompt_id: {prompt_id}")
deadline = time.time() + TIMEOUT_S
last_sig = None
history_entry = None
while time.time() < deadline:
state = page.evaluate(
f"""async () => {{
const h = await fetch('/history/{prompt_id}').then(r => r.json());
const q = await fetch('/queue').then(r => r.json());
return {{ h, q }};
}}"""
)
q = state["q"]
sig = f"running={len(q.get('queue_running', []))} pending={len(q.get('queue_pending', []))}"
if sig != last_sig:
print(f"[{int(TIMEOUT_S - (deadline - time.time())):>3}s] {sig}")
last_sig = sig
if prompt_id in state["h"]:
history_entry = state["h"][prompt_id]
break
time.sleep(3)
if history_entry is None:
fail(f"prompt did not complete within {TIMEOUT_S}s", 2)
status = history_entry.get("status", {})
messages = status.get("messages", [])
status_str = status.get("status_str")
print(f"status_str: {status_str}")
exec_errors = [m[1] for m in messages if m[0] == "execution_error"]
for err in exec_errors:
print(f"[ERROR] node={err.get('node_id')} {err.get('exception_type')}: {err.get('exception_message')}")
for t in err.get("traceback", [])[-5:]:
print(f" {t.strip()}")
if exec_errors:
fail("execution_error present (DD compat shim or unrelated)", 1)
if status_str != "success":
fail(f"status_str={status_str!r}")
outputs = history_entry.get("outputs", {})
if "preview_input" not in outputs or "preview_paste" not in outputs:
fail(f"expected previews missing from outputs: {list(outputs)}")
def fetch_png_stats(meta):
qs = urllib.parse.urlencode(
{
"filename": meta.get("filename", ""),
"subfolder": meta.get("subfolder", ""),
"type": meta.get("type", "output"),
}
)
raw_list = page.evaluate(
f"""async () => {{
const r = await fetch('/view?{qs}');
if (!r.ok) return null;
const ab = await r.arrayBuffer();
return Array.from(new Uint8Array(ab));
}}"""
)
if not raw_list:
return None
import io as _io
import numpy as np
from PIL import Image as PILImage
pim = PILImage.open(_io.BytesIO(bytes(raw_list)))
arr = np.array(pim)
return {
"size": pim.size,
"mean": float(arr.mean()),
"std": float(arr.std()),
}
in_stats = fetch_png_stats(outputs["preview_input"]["images"][0])
out_stats = fetch_png_stats(outputs["preview_paste"]["images"][0])
if in_stats is None or out_stats is None:
fail("could not fetch one or both preview images")
print(f"input : {in_stats}")
print(f"paste : {out_stats}")
if out_stats["std"] < 1.0:
fail("paste output is degenerate (flat image)")
if in_stats["size"] != out_stats["size"]:
fail(f"size mismatch: {in_stats['size']} vs {out_stats['size']}")
# SEGSPaste with a detailed face should produce a slightly different mean/std
# vs the untouched input. Exact equality would indicate the detailer path
# (and thus the DD compat shim) was bypassed.
mean_delta = abs(out_stats["mean"] - in_stats["mean"])
std_delta = abs(out_stats["std"] - in_stats["std"])
if mean_delta < 0.005 and std_delta < 0.005:
fail(
f"output identical to input (mean_delta={mean_delta:.4f}, "
f"std_delta={std_delta:.4f}) — detailer likely didn't run"
)
print(f"PASS: detailer ran, mean_delta={mean_delta:.4f}, std_delta={std_delta:.4f}")
browser.close()
if __name__ == "__main__":
main()