Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
429d0159ad | ||
|
|
5bed9d5545 | ||
|
|
d74c1d0112 | ||
|
|
6a517ebe06 | ||
|
|
762fecd970 | ||
|
|
51b7dcdffa | ||
|
|
4c864fafb0 | ||
|
|
61bd8397a1 |
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user