fix: cross-version compat for DifferentialDiffusion + E2E test harness
Resolves `AttributeError: 'DifferentialDiffusion' object has no attribute 'execute'` on older ComfyUI (<0.3.63) where the V3 schema migration has not landed yet. The prior code called `.execute(model)[0]` directly, breaking anyone still on the legacy `.apply(model)` API. Introduce `modules/impact/utils.apply_differential_diffusion(model)` which uses `hasattr` to pick the correct branch at runtime: - `.execute` -> V3 classmethod (ComfyUI >= 0.3.63) - `.apply` -> legacy instance method (older) - otherwise -> AttributeError with upgrade guidance Replace all 7 direct call sites (animatediff_nodes, core x2, impact_pack x2, segs_nodes, segs_upscaler) with the helper. The existing import guard in segs_upscaler is preserved; the helper's deferred import means ImportError still propagates for very old ComfyUI builds without `comfy_extras.nodes_differential_diffusion`. Fixes GitHub issue #1177. Also add an E2E test harness: - docs/E2E_TEST_STRATEGY.md — isolated-launch workflow with --disable-all-custom-nodes + --whitelist-custom-nodes, Playwright smoke test, workflow execution via /prompt + /history + /view, verification criteria, operational notes. - tests/e2e_dd_compat.py — reference test that runs SEGSDetailer with noise_mask_feather=20 on a real input image, asserting status==success and non-zero pixel delta between input and paste previews (proves the detailer loop and compat shim actually ran).
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
"""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.
|
||||
|
||||
To verify the helper's .execute branch is actually taken on the current
|
||||
ComfyUI version, temporarily add a `logging.warning(...)` inside
|
||||
`modules/impact/utils.py::apply_differential_diffusion` and grep the server
|
||||
log for the marker. See
|
||||
`.claude/livecontext/analysis/2026-04-20_glarybot_v3_migration_proposal_review.md`
|
||||
for broader context on the DD compat story.
|
||||
"""
|
||||
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