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);
|
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 { app } from "../../scripts/app.js";
|
||||||
|
import { readLinkedNumber, getDrawColor, computeCanvasSize } from "./common.js";
|
||||||
function showPreviewCanvas(node, app) {
|
function showPreviewCanvas(node, app) {
|
||||||
|
|
||||||
const widget = {
|
const widget = {
|
||||||
@@ -15,7 +16,7 @@ function showPreviewCanvas(node, app) {
|
|||||||
// If we are initially offscreen when created we wont have received a resize event
|
// If we are initially offscreen when created we wont have received a resize event
|
||||||
// Calculate it here instead
|
// Calculate it here instead
|
||||||
if (!node.canvasHeight) {
|
if (!node.canvasHeight) {
|
||||||
computeCanvasSize(node, node.size);
|
computeCanvasSize(node, node.size, 220, 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = true;
|
const visible = true;
|
||||||
@@ -23,9 +24,16 @@ function showPreviewCanvas(node, app) {
|
|||||||
const margin = 12;
|
const margin = 12;
|
||||||
const border = 2;
|
const border = 2;
|
||||||
const widgetHeight = node.canvasHeight;
|
const widgetHeight = node.canvasHeight;
|
||||||
const width = Math.round(node.properties["width"]);
|
|
||||||
const height = Math.round(node.properties["height"]);
|
// Keep preview in sync when inputs are driven by links.
|
||||||
const scale = Math.min((widgetWidth - margin * 3) / width, (widgetHeight - margin * 3) / height);
|
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 blurRadius = node.properties["blur_radius"] || 0;
|
||||||
const index = 0;
|
const index = 0;
|
||||||
|
|
||||||
@@ -120,11 +128,11 @@ function showPreviewCanvas(node, app) {
|
|||||||
xOffset += (widgetWidth - backgroundWidth) / 2 - margin;
|
xOffset += (widgetWidth - backgroundWidth) / 2 - margin;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajustar las coordenadas X e Y
|
// Adjust X and Y coordinates
|
||||||
const barHeight = 8;
|
const barHeight = 8;
|
||||||
let widgetYBar = widgetY + backgroundHeight + margin;
|
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.fillStyle = globalThis.LiteGraph.WIDGET_OUTLINE_COLOR;
|
||||||
ctx.fillRect(
|
ctx.fillRect(
|
||||||
widgetX - border,
|
widgetX - border,
|
||||||
@@ -133,8 +141,8 @@ function showPreviewCanvas(node, app) {
|
|||||||
barHeight + border * 2
|
barHeight + border * 2
|
||||||
);
|
);
|
||||||
|
|
||||||
// Dibujar el área principal de la barra (fondo)
|
// Draw the main bar area (background)
|
||||||
ctx.fillStyle = globalThis.LiteGraph.WIDGET_BGCOLOR; // Mismo color de fondo que el canvas
|
ctx.fillStyle = globalThis.LiteGraph.WIDGET_BGCOLOR;
|
||||||
ctx.fillRect(
|
ctx.fillRect(
|
||||||
widgetX,
|
widgetX,
|
||||||
widgetYBar,
|
widgetYBar,
|
||||||
@@ -142,16 +150,15 @@ function showPreviewCanvas(node, app) {
|
|||||||
barHeight
|
barHeight
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Draw progress bar grid
|
// Draw progress bar grid
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeStyle = "#66666650";
|
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);
|
const numLines = Math.floor(backgroundWidth / 64);
|
||||||
|
|
||||||
// Dibujar líneas del grid
|
// Draw grid lines
|
||||||
for (let x = 0; x <= width / 64; x += 1) {
|
for (let x = 0; x <= width / 64; x += 1) {
|
||||||
ctx.moveTo(widgetX + x * 64 * scale, widgetYBar);
|
ctx.moveTo(widgetX + x * 64 * scale, widgetYBar);
|
||||||
ctx.lineTo(widgetX + x * 64 * scale, widgetYBar + barHeight);
|
ctx.lineTo(widgetX + x * 64 * scale, widgetYBar + barHeight);
|
||||||
@@ -159,7 +166,7 @@ function showPreviewCanvas(node, app) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
|
|
||||||
// Dibujar progreso (basado en blur_radius)
|
// Draw progress (based on blur_radius)
|
||||||
const progress = Math.min(blurRadius / 255, 1);
|
const progress = Math.min(blurRadius / 255, 1);
|
||||||
ctx.fillStyle = "rgba(0, 120, 255, 0.5)";
|
ctx.fillStyle = "rgba(0, 120, 255, 0.5)";
|
||||||
|
|
||||||
@@ -176,6 +183,13 @@ function showPreviewCanvas(node, app) {
|
|||||||
widget.canvas.className = "mask-rect-area-canvas";
|
widget.canvas.className = "mask-rect-area-canvas";
|
||||||
widget.parent = node;
|
widget.parent = node;
|
||||||
|
|
||||||
|
widget.computeLayoutSize = function (node) {
|
||||||
|
return {
|
||||||
|
minHeight: 200,
|
||||||
|
maxHeight: 300
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
document.body.appendChild(widget.canvas);
|
document.body.appendChild(widget.canvas);
|
||||||
node.addCustomWidget(widget);
|
node.addCustomWidget(widget);
|
||||||
|
|
||||||
@@ -196,32 +210,92 @@ function showPreviewCanvas(node, app) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
node.onResize = function (size) {
|
node.onResize = function (size) {
|
||||||
computeCanvasSize(node, size);
|
computeCanvasSize(node, size, 220, 240);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {minWidth: 200, minHeight: 200, widget};
|
return {minWidth: 200, minHeight: 200, widget};
|
||||||
}
|
}
|
||||||
|
|
||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: 'drltdata.MaskRectAreaAdvanced',
|
name: "drltdata.MaskRectAreaAdvanced",
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeData.name === "MaskRectAreaAdvanced") {
|
if (nodeData.name !== "MaskRectAreaAdvanced") {
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
return;
|
||||||
nodeType.prototype.onNodeCreated = function () {
|
}
|
||||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
|
||||||
|
|
||||||
this.setProperty("width", 512);
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
this.setProperty("height", 512);
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
this.setProperty("x", 0);
|
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||||
this.setProperty("y", 0);
|
|
||||||
this.setProperty("w", 256);
|
|
||||||
this.setProperty("h", 256);
|
|
||||||
this.setProperty("blur_radius", 0);
|
|
||||||
|
|
||||||
this.selected = false;
|
this.setProperty("width", 512);
|
||||||
this.index = 3;
|
this.setProperty("height", 512);
|
||||||
this.serialize_widgets = true;
|
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) {
|
CUSTOM_INT(this, "x", 0, function (v, _, node) {
|
||||||
const s = this.options.step / 10;
|
const s = this.options.step / 10;
|
||||||
this.value = Math.round(v / s) * s;
|
this.value = Math.round(v / s) * s;
|
||||||
@@ -258,19 +332,19 @@ app.registerExtension({
|
|||||||
},
|
},
|
||||||
{"min": 0, "max": 255, "step": 10}
|
{"min": 0, "max": 255, "step": 10}
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
showPreviewCanvas(this, app);
|
showPreviewCanvas(this, app);
|
||||||
|
|
||||||
this.onSelected = function () {
|
this.onSelected = function () {
|
||||||
this.selected = true;
|
this.selected = true;
|
||||||
};
|
|
||||||
this.onDeselected = function () {
|
|
||||||
this.selected = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
return r;
|
|
||||||
};
|
};
|
||||||
}
|
this.onDeselected = function () {
|
||||||
|
this.selected = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return r;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -311,71 +385,75 @@ function CUSTOM_INT(node, inputName, val, func, config = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDrawColor(percent, alpha) {
|
function syncLinkedInputsToPropertiesAdvanced(node) {
|
||||||
let h = 360 * percent;
|
let changed = false;
|
||||||
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 computeCanvasSize(node, size) {
|
const vx = readLinkedNumber(node, "x");
|
||||||
if (node.widgets[0].last_y == null) {
|
if (vx != null) {
|
||||||
return;
|
const nv = Math.max(0, Math.round(vx));
|
||||||
}
|
if (node.properties["x"] !== nv) {
|
||||||
|
node.properties["x"] = nv;
|
||||||
const MIN_HEIGHT = 220;
|
changed = true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure there is enough vertical space
|
const vy = readLinkedNumber(node, "y");
|
||||||
freeSpace -= widgetHeight;
|
if (vy != null) {
|
||||||
|
const nv = Math.max(0, Math.round(vy));
|
||||||
// Adjust the height of the node if needed
|
if (node.properties["y"] !== nv) {
|
||||||
if (freeSpace < MIN_HEIGHT) {
|
node.properties["y"] = nv;
|
||||||
freeSpace = MIN_HEIGHT;
|
changed = true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { app } from "../../scripts/app.js";
|
||||||
|
import { readLinkedNumber, getDrawColor, computeCanvasSize } from "./common.js";
|
||||||
function showPreviewCanvas(node, app) {
|
function showPreviewCanvas(node, app) {
|
||||||
|
|
||||||
const widget = {
|
const widget = {
|
||||||
@@ -15,7 +16,7 @@ function showPreviewCanvas(node, app) {
|
|||||||
// If we are initially offscreen when created we wont have received a resize event
|
// If we are initially offscreen when created we wont have received a resize event
|
||||||
// Calculate it here instead
|
// Calculate it here instead
|
||||||
if (!node.canvasHeight) {
|
if (!node.canvasHeight) {
|
||||||
computeCanvasSize(node, node.size);
|
computeCanvasSize(node, node.size, 200, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = true;
|
const visible = true;
|
||||||
@@ -64,6 +65,9 @@ function showPreviewCanvas(node, app) {
|
|||||||
ctx.fillStyle = globalThis.LiteGraph.WIDGET_BGCOLOR;
|
ctx.fillStyle = globalThis.LiteGraph.WIDGET_BGCOLOR;
|
||||||
ctx.fillRect(widgetX, widgetY, backgroundWidth, backgroundHeight);
|
ctx.fillRect(widgetX, widgetY, backgroundWidth, backgroundHeight);
|
||||||
|
|
||||||
|
// Keep preview in sync when inputs are driven by links.
|
||||||
|
syncLinkedInputsToProperties(node);
|
||||||
|
|
||||||
// Draw the conditioning zone
|
// Draw the conditioning zone
|
||||||
let [x, y, w, h] = getDrawArea(node, backgroundWidth, backgroundHeight);
|
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.strokeStyle = globalThis.LiteGraph.NODE_SELECTED_TITLE_COLOR;
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.strokeRect(widgetX + sx, widgetY + sy, sw, sh);
|
ctx.strokeRect(widgetX + sx, widgetY + sy, sw, sh);
|
||||||
//ctx.strokeRect(finalSX, finalSY, finalSW, finalSH);
|
|
||||||
|
|
||||||
// Display
|
// Display
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -173,6 +176,13 @@ function showPreviewCanvas(node, app) {
|
|||||||
widget.canvas.className = "mask-rect-area-canvas";
|
widget.canvas.className = "mask-rect-area-canvas";
|
||||||
widget.parent = node;
|
widget.parent = node;
|
||||||
|
|
||||||
|
widget.computeLayoutSize = function (node) {
|
||||||
|
return {
|
||||||
|
minHeight: 200,
|
||||||
|
maxHeight: 300
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
document.body.appendChild(widget.canvas);
|
document.body.appendChild(widget.canvas);
|
||||||
node.addCustomWidget(widget);
|
node.addCustomWidget(widget);
|
||||||
|
|
||||||
@@ -193,7 +203,7 @@ function showPreviewCanvas(node, app) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
node.onResize = function (size) {
|
node.onResize = function (size) {
|
||||||
computeCanvasSize(node, size);
|
computeCanvasSize(node, size, 200, 200);
|
||||||
};
|
};
|
||||||
|
|
||||||
return {minWidth: 200, minHeight: 200, widget};
|
return {minWidth: 200, minHeight: 200, widget};
|
||||||
@@ -202,25 +212,82 @@ function showPreviewCanvas(node, app) {
|
|||||||
app.registerExtension({
|
app.registerExtension({
|
||||||
name: 'drltdata.MaskRectArea',
|
name: 'drltdata.MaskRectArea',
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||||
if (nodeData.name === "MaskRectArea") {
|
if (nodeData.name !== "MaskRectArea") {
|
||||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
return;
|
||||||
nodeType.prototype.onNodeCreated = function () {
|
}
|
||||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
|
||||||
|
|
||||||
this.setProperty("width", 512);
|
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||||
this.setProperty("height", 512);
|
nodeType.prototype.onNodeCreated = function () {
|
||||||
this.setProperty("x", 0);
|
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
|
||||||
this.setProperty("y", 0);
|
|
||||||
this.setProperty("w", 50);
|
|
||||||
this.setProperty("h", 50);
|
|
||||||
this.setProperty("blur_radius", 0);
|
|
||||||
|
|
||||||
this.selected = false;
|
this.setProperty("width", 512);
|
||||||
this.index = 3;
|
this.setProperty("height", 512);
|
||||||
this.serialize_widgets = true;
|
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) {
|
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;
|
node.properties["x"] = this.value;
|
||||||
});
|
});
|
||||||
CUSTOM_INT(this, "y", 0, function (v, _, node) {
|
CUSTOM_INT(this, "y", 0, function (v, _, node) {
|
||||||
@@ -238,25 +305,104 @@ app.registerExtension({
|
|||||||
CUSTOM_INT(this, "blur_radius", 0, function (v, _, node) {
|
CUSTOM_INT(this, "blur_radius", 0, function (v, _, node) {
|
||||||
this.value = Math.round(v) || 0;
|
this.value = Math.round(v) || 0;
|
||||||
node.properties["blur_radius"] = this.value;
|
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 () {
|
showPreviewCanvas(this, app);
|
||||||
this.selected = true;
|
|
||||||
};
|
// Sync linked input values -> node.properties so the preview updates when driven by connections.
|
||||||
this.onDeselected = function () {
|
const prevOnExecute = this.onExecute;
|
||||||
this.selected = false;
|
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.
|
// Calculate the drawing area using percentage-based properties.
|
||||||
function getDrawArea(node, backgroundWidth, backgroundHeight) {
|
function getDrawArea(node, backgroundWidth, backgroundHeight) {
|
||||||
// Convert percentages to actual pixel values based on the background dimensions
|
// 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) {
|
function syncLinkedInputsToProperties(node) {
|
||||||
let h = 360 * percent;
|
let changed = false;
|
||||||
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 computeCanvasSize(node, size) {
|
const vx = readLinkedNumber(node, "x");
|
||||||
if (node.widgets[0].last_y == null) {
|
if (vx != null) {
|
||||||
return;
|
const nv = Math.max(0, Math.min(100, Math.round(vx)));
|
||||||
}
|
if (node.properties["x"] !== nv) {
|
||||||
|
node.properties["x"] = nv;
|
||||||
const MIN_HEIGHT = 200;
|
changed = true;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure there is enough vertical space
|
const vy = readLinkedNumber(node, "y");
|
||||||
freeSpace -= widgetHeight;
|
if (vy != null) {
|
||||||
|
const nv = Math.max(0, Math.min(100, Math.round(vy)));
|
||||||
// Adjust the height of the node if needed
|
if (node.properties["y"] !== nv) {
|
||||||
if (freeSpace < MIN_HEIGHT) {
|
node.properties["y"] = nv;
|
||||||
freeSpace = MIN_HEIGHT;
|
changed = true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = []
|
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:
|
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]:
|
for seg in segs[1]:
|
||||||
cropped_image_frames = None
|
cropped_image_frames = None
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import configparser
|
|||||||
import logging
|
import logging
|
||||||
import os
|
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 '')
|
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__)
|
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)
|
noise_mask = noise_mask.squeeze(3)
|
||||||
|
|
||||||
if noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
|
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 != "":
|
if wildcard_opt is not None and wildcard_opt != "":
|
||||||
model, _, wildcard_positive = wildcards.process_with_loras(wildcard_opt, model, clip)
|
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)
|
noise_mask = noise_mask.squeeze(3)
|
||||||
|
|
||||||
if noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
|
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 != "":
|
if wildcard_opt is not None and wildcard_opt != "":
|
||||||
model, _, wildcard_positive = wildcards.process_with_loras(wildcard_opt, model, clip)
|
model, _, wildcard_positive = wildcards.process_with_loras(wildcard_opt, model, clip)
|
||||||
|
|||||||
+103
-52
@@ -301,7 +301,7 @@ class DetailerForEach:
|
|||||||
ordered_segs = segs[1]
|
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:
|
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):
|
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
|
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]
|
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:
|
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):
|
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
|
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):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
"required": {
|
"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"}
|
"hidden": {"extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}
|
||||||
}
|
}
|
||||||
@@ -2166,34 +2172,68 @@ class MaskRectArea:
|
|||||||
CATEGORY = "ImpactPack/Operation"
|
CATEGORY = "ImpactPack/Operation"
|
||||||
FUNCTION = "create_mask"
|
FUNCTION = "create_mask"
|
||||||
|
|
||||||
def create_mask(self, extra_pnginfo, unique_id, **kwargs):
|
def create_mask(self, x, y, width, height, blur_radius, extra_pnginfo, unique_id):
|
||||||
# search for node
|
# Backward-compat: if node properties exist in workflow, prefer them.
|
||||||
node_found = False
|
try:
|
||||||
for node in extra_pnginfo["workflow"]["nodes"]:
|
for node in extra_pnginfo["workflow"]["nodes"]:
|
||||||
if str(node["id"]) == unique_id:
|
if str(node["id"]) == str(unique_id):
|
||||||
min_x = node["properties"].get("x", 0) / 100
|
props = node.get("properties", {})
|
||||||
min_y = node["properties"].get("y", 0) / 100
|
x = int(props.get("x", x))
|
||||||
width = node["properties"].get("w", 0) / 100
|
y = int(props.get("y", y))
|
||||||
height = node["properties"].get("h", 0) / 100
|
width = int(props.get("w", width))
|
||||||
blur_radius = node["properties"].get("blur_radius", 0)
|
height = int(props.get("h", height))
|
||||||
node_found = True
|
blur_radius = int(props.get("blur_radius", blur_radius))
|
||||||
break
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if not node_found:
|
# Clamp percent inputs
|
||||||
raise ValueError(f"No node found with unique_id {unique_id}.")
|
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)
|
# Create a mask with standard resolution (e.g., 512x512)
|
||||||
resolution = 512
|
resolution = 512
|
||||||
mask = torch.zeros((resolution, resolution))
|
mask = torch.zeros((resolution, resolution), dtype=torch.float32)
|
||||||
|
|
||||||
# Calculate pixel coordinates
|
# Calculate pixel coordinates
|
||||||
min_x_px = int(min_x * resolution)
|
min_x_px = int(min_x * resolution)
|
||||||
min_y_px = int(min_y * resolution)
|
min_y_px = int(min_y * resolution)
|
||||||
max_x_px = int((min_x + width) * resolution)
|
max_x_px = int((min_x + w_ratio) * resolution)
|
||||||
max_y_px = int((min_y + height) * 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
|
# 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
|
# Apply blur if the radii are greater than 0
|
||||||
if blur_radius > 0:
|
if blur_radius > 0:
|
||||||
@@ -2222,6 +2262,13 @@ class MaskRectAreaAdvanced:
|
|||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
"required": {
|
"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"}
|
"hidden": {"extra_pnginfo": "EXTRA_PNGINFO", "unique_id": "UNIQUE_ID"}
|
||||||
}
|
}
|
||||||
@@ -2231,46 +2278,50 @@ class MaskRectAreaAdvanced:
|
|||||||
CATEGORY = "ImpactPack/Operation"
|
CATEGORY = "ImpactPack/Operation"
|
||||||
FUNCTION = "create_mask_advanced"
|
FUNCTION = "create_mask_advanced"
|
||||||
|
|
||||||
def create_mask_advanced(self, extra_pnginfo, unique_id, **kwargs):
|
def create_mask_advanced(self, x, y, width, height, image_width, image_height, blur_radius, extra_pnginfo, unique_id):
|
||||||
# search for node
|
# Backward-compat fallback: if node properties exist in workflow, prefer them
|
||||||
node_found = False
|
try:
|
||||||
for node in extra_pnginfo["workflow"]["nodes"]:
|
for node in extra_pnginfo["workflow"]["nodes"]:
|
||||||
if node["id"] == int(unique_id):
|
if node["id"] == int(unique_id):
|
||||||
min_x = node["properties"]["x"]
|
props = node.get("properties", {})
|
||||||
min_y = node["properties"]["y"]
|
x = int(props.get("x", x))
|
||||||
width = node["properties"]["w"]
|
y = int(props.get("y", y))
|
||||||
height = node["properties"]["h"]
|
width = int(props.get("w", width))
|
||||||
image_width = node["properties"]["width"]
|
height = int(props.get("h", height))
|
||||||
image_height = node["properties"]["height"]
|
image_width = int(props.get("width", image_width))
|
||||||
blur_radius = node["properties"]["blur_radius"]
|
image_height = int(props.get("height", image_height))
|
||||||
node_found = True
|
blur_radius = int(props.get("blur_radius", blur_radius))
|
||||||
break
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if not node_found:
|
# Clamp to safe bounds
|
||||||
raise ValueError(f"No node found with unique_id {unique_id}.")
|
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, image_width)
|
||||||
max_x = min_x + width
|
max_y = min(y + height, image_height)
|
||||||
max_y = min_y + height
|
|
||||||
|
|
||||||
# Create a mask with the image dimensions
|
mask = torch.zeros((image_height, image_width), dtype=torch.float32)
|
||||||
mask = torch.zeros((image_height, image_width))
|
|
||||||
|
|
||||||
# Draw the rectangle on the mask
|
if max_x > x and max_y > y:
|
||||||
mask[int(min_y):int(max_y), int(min_x):int(max_x)] = 1
|
mask[y:max_y, x:max_x] = 1.0
|
||||||
|
|
||||||
# Apply blur if the radii are greater than 0
|
# Apply blur if the radii are greater than 0
|
||||||
if blur_radius > 0:
|
if blur_radius > 0:
|
||||||
dx = blur_radius * 2 + 1
|
k = blur_radius * 2 + 1
|
||||||
dy = blur_radius * 2 + 1
|
|
||||||
|
|
||||||
# Convert the mask to a format compatible with OpenCV (numpy array)
|
|
||||||
mask_np = mask.cpu().numpy().astype("float32")
|
mask_np = mask.cpu().numpy().astype("float32")
|
||||||
|
blurred_mask = cv2.GaussianBlur(mask_np, (k, k), 0)
|
||||||
# Apply Gaussian Blur
|
|
||||||
blurred_mask = cv2.GaussianBlur(mask_np, (dx, dy), 0)
|
|
||||||
|
|
||||||
# Convert back to tensor
|
|
||||||
mask = torch.from_numpy(blurred_mask)
|
mask = torch.from_numpy(blurred_mask)
|
||||||
|
|
||||||
# Return the mask as a tensor with an additional channel
|
# Return the mask as a tensor with an additional channel
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class SEGSDetailer:
|
|||||||
cnet_pil_list = []
|
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:
|
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):
|
for i in range(batch_size):
|
||||||
seed += 1
|
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)
|
noise_mask = noise_mask.squeeze(3)
|
||||||
|
|
||||||
if noise_mask_feather > 0 and 'denoise_mask_function' not in model.model_options:
|
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:
|
if control_net_wrapper is not None:
|
||||||
positive, negative, _ = control_net_wrapper.apply(positive, negative, image, noise_mask)
|
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.")
|
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 --->
|
# author: Trung0246 --->
|
||||||
class TautologyStr(str):
|
class TautologyStr(str):
|
||||||
def __ne__(self, other):
|
def __ne__(self, other):
|
||||||
|
|||||||
+13
-2
@@ -1,9 +1,20 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-impact-pack"
|
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."
|
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" }
|
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]
|
[project.urls]
|
||||||
Repository = "https://github.com/ltdrdata/ComfyUI-Impact-Pack"
|
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