Compare commits

...
22 Commits
Author SHA1 Message Date
Daisy ec49b685db chore(release): 1.11.1 [skip ci]
## [1.11.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.0...v1.11.1) (2026-09-25)

### Bug Fixes

* **release:** attribute automation to Daisy ([2ae545d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/2ae545d64d70a454f635ee647fb7e6a1c3500b9b))
2026-09-25 05:07:53 +00:00
Artificial Sweetener 2ae545d64d fix(release): attribute automation to Daisy 2026-09-25 00:58:56 -04:00
Daisy 81b7a8fe63 chore(release): 1.11.0 [skip ci]
# [1.11.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.1...v1.11.0) (2026-09-25)

### Features

* **sampling:** make negative conditioning optional ([0bc81dc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0bc81dc4d00a13d42c65440da2058e94a505e8d5))
2026-09-25 03:46:10 +00:00
Artificial Sweetener 2fc10b0c5e feat(sampling): make negative conditioning optional 2026-09-24 23:39:42 -04:00
Daisy e7ff15d5a7 chore(release): 1.10.1 [skip ci]
## [1.10.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.0...v1.10.1) (2026-09-24)

### Bug Fixes

* **ci:** expose test support to compatibility jobs ([0517f71](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0517f71da891414e9773c5cd47879ff748492e70))
* **governance:** enforce SugarSubstitute quality standards ([bbaed2c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/bbaed2c90656cd4e65e5bfcd4c0451f90ec2d7c7))
2026-09-24 22:23:06 +00:00
Artificial Sweetener 5bc3200832 fix(ci): expose test support to compatibility jobs 2026-09-24 18:16:25 -04:00
Artificial Sweetener c484e9d236 fix(governance): enforce SugarSubstitute quality standards 2026-09-24 18:08:33 -04:00
Daisy dc6a0723a5 chore(release): 1.10.0 [skip ci]
# [1.10.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.3...v1.10.0) (2026-09-21)

### Features

* **loaders:** add Krea 2 model loader ([166f029](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/166f029d12e5fce019133cc38be7279d05a5ecbc))
2026-09-21 04:23:59 +00:00
Artificial Sweetener 6184b74809 feat(loaders): add Krea 2 model loader
Add automatic FP8/BF16 Qwen encoder and shared VAE resolution, architecture validation, artifact-aware dropdown deduplication, and renamed artifact discovery across supported loaders.
2026-09-21 00:17:31 -04:00
Daisy 9948cb3433 chore(release): 1.9.3 [skip ci]
## [1.9.3](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.2...v1.9.3) (2026-09-20)

### Bug Fixes

* **attention-coupling:** restore regional LoRA sampling ([0255a0f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0255a0f5044278f14452b6b2582ec6646083f756))
2026-09-20 21:17:02 +00:00
Artificial Sweetener 6cb9bbe868 fix(attention-coupling): restore regional LoRA sampling 2026-09-20 17:07:51 -04:00
Daisy 561b73630c chore(release): 1.9.2 [skip ci]
## [1.9.2](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.1...v1.9.2) (2026-09-20)

### Bug Fixes

* **registry:** remove flagged package content ([f3a53b5](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f3a53b5ec6c080d98f7e9599cf55e849cf338021))
2026-09-20 03:15:34 +00:00
Artificial Sweetener 51efa670e0 fix(registry): remove flagged package content 2026-09-19 23:08:05 -04:00
Daisy d188a3764b chore(release): 1.9.1 [skip ci]
## [1.9.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.0...v1.9.1) (2026-09-20)

### Bug Fixes

* **contextual-diffusion:** project reference latents into views ([4cd780a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4cd780a2451aa472ce826834e4426b65693c46e8))
2026-09-20 02:14:41 +00:00
Artificial Sweetener f1d0630729 fix(contextual-diffusion): project reference latents into views 2026-09-19 22:06:33 -04:00
Daisy 0cd1032073 chore(release): 1.9.0 [skip ci]
# [1.9.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.8.0...v1.9.0) (2026-09-19)

### Features

* **prompts:** add automatic NegPiP support ([6d052e9](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6d052e9800985696972bf431fa7dae4972a56313))
2026-09-19 20:44:48 +00:00
Artificial Sweetener d01b085082 feat(prompts): add automatic NegPiP support 2026-09-19 16:39:12 -04:00
Daisy 0583ba2675 chore(release): 1.8.0 [skip ci]
# [1.8.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.7.1...v1.8.0) (2026-09-19)

### Bug Fixes

* **downloads:** keep unknown sizes indeterminate ([a31467c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a31467cd3a4299e9e3929d44281018dba0322b3e))
* **models:** hide installed catalog choices ([d887e87](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d887e87e03eb6d0d9d5325fe43b67fbd9e47cf71))

### Features

* **models:** add curated ultralytics downloads ([b907fa2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b907fa2a17afa30170bc22cf1134a750241e55c2))
* **models:** prioritize installed ultralytics choices ([d30e04f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d30e04f229366d3e1d2388bd4d713b2b47a12a1f))
2026-09-19 16:39:28 +00:00
Artificial Sweetener dcc37d7ab6 fix(downloads): keep unknown sizes indeterminate 2026-09-19 10:48:43 -04:00
Artificial Sweetener 583b22a4bf feat(models): prioritize installed ultralytics choices 2026-09-19 10:32:49 -04:00
Artificial Sweetener ebe01efc49 fix(models): hide installed catalog choices 2026-09-19 00:37:32 -04:00
Artificial Sweetener 41e8a2b61c feat(models): add curated ultralytics downloads 2026-09-19 00:25:58 -04:00
942 changed files with 26400 additions and 10314 deletions
+13
View File
@@ -0,0 +1,13 @@
.github/
tests/
tools/
scripts/
web/src/
web/tests/
AGENTS.md
.releaserc.cjs
eslint.config.js
package-lock.json
package.json
tsconfig.json
vitest.config.ts
+62
View File
@@ -0,0 +1,62 @@
name: quality gates
on:
pull_request:
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
env:
SIMPLE_SYRUP_TEST_COMFY_CPU: "1"
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22.14.0
cache: npm
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Node dependencies
run: npm ci
- name: Install ComfyUI host dependencies
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/comfyanonymous/ComfyUI.git "$RUNNER_TEMP/ComfyUI"
rsync -a --exclude=".git" "$RUNNER_TEMP/ComfyUI/" "$GITHUB_WORKSPACE/../.."/
pip install -r "$GITHUB_WORKSPACE/../../requirements.txt"
- name: Install Python dependencies
run: pip install -e . pytest pytest-xdist ruff mypy
- name: Check architecture governance
run: python -m tools.check_architecture
- name: Check test governance
run: python -m tools.check_test_governance
- name: Verify Python formatting
run: ruff format --check .
- name: Verify Python lint
run: ruff check .
- name: Verify Python types
run: mypy --strict simple_syrup tests
- name: Verify Python tests
run: pytest -n auto -q -m "not external_artifact"
- name: Verify frontend
run: npm run check:web
+14 -2
View File
@@ -50,13 +50,15 @@ jobs:
"transformers${{ matrix.version }}"
- name: Verify GroundingDINO BERT compatibility
env:
PYTHONPATH: ${{ github.workspace }}/tests
run: >-
python -m pytest -q
--noconftest
--rootdir=tests
--confcutdir=tests
tests/test_grounding_dino_bert_adapter.py
tests/test_grounding_dino_text_token_masks.py
tests/segmentation/detection/test_grounding_dino_bert_adapter.py
tests/segmentation/detection/test_grounding_dino_text_token_masks.py
release:
if: github.event_name != 'pull_request'
@@ -98,6 +100,12 @@ jobs:
- name: Verify Python formatting
run: ruff format --check .
- name: Check architecture governance
run: python -m tools.check_architecture
- name: Check test governance
run: python -m tools.check_test_governance
- name: Verify Python lint
run: ruff check .
@@ -114,6 +122,10 @@ jobs:
id: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_AUTHOR_NAME: Daisy
GIT_AUTHOR_EMAIL: daisy@artificialsweetener.ai
GIT_COMMITTER_NAME: Daisy
GIT_COMMITTER_EMAIL: daisy@artificialsweetener.ai
shell: bash
run: |
set -euo pipefail
+29
View File
@@ -0,0 +1,29 @@
repos:
- repo: local
hooks:
- id: architecture-governance
name: Enforce architecture governance
entry: ..\..\venv\Scripts\python.exe -m tools.check_architecture
language: system
pass_filenames: false
always_run: true
- id: test-governance
name: Enforce test governance
entry: ..\..\venv\Scripts\python.exe -m tools.check_test_governance
language: system
pass_filenames: false
always_run: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=lf]
exclude: '(\.bat$|\.cmd$|\.ps1$)'
- id: trailing-whitespace
- id: check-merge-conflict
- id: check-yaml
- id: check-json
exclude: '(^web/dist/|tsconfig\.json$)'
- id: check-toml
+30
View File
@@ -35,6 +35,8 @@ Engineering priority is strict architecture, strong separation of concerns, comp
### Required Command Forms
- Tests: `..\..\venv\Scripts\python.exe -m pytest -n auto -q`
- Architecture: `..\..\venv\Scripts\python.exe -m tools.check_architecture`
- Test governance: `..\..\venv\Scripts\python.exe -m tools.check_test_governance`
- Lint: `..\..\venv\Scripts\ruff.exe check .`
- Format: `..\..\venv\Scripts\ruff.exe format .`
- Type check: `..\..\venv\Scripts\mypy.exe --strict simple_syrup tests`
@@ -94,6 +96,34 @@ If a required tool is missing from `..\..\venv`, install or update development d
- Reorganize modules when it improves architecture.
- Align touched modules with the ownership and dependency rules in this file.
## Architecture Governance
- Repository governance lives under `governance/`.
- `governance/architecture/policy.toml` defines every authored-code root,
extension, exclusion, and the 350-line soft and 500-line hard structural
thresholds.
- `governance/architecture/debt.toml` records exact assessed mixed ownership.
- `governance/architecture/waivers.toml` records exact bounded hard-gate
exceptions.
- `governance/architecture/import_debt.toml` records exact current dependency-
direction violations; new violations are prohibited.
- `governance/architecture/soft_reviews.toml` records the current human
disposition of every file between the soft and hard thresholds.
- Every hard-gate file requires source-level ownership review.
- Use a structural waiver only for one cohesive authoritative owner whose
invariants would be divided by extraction.
- Mixed ownership requires debt and a linked remediation waiver naming the
next extraction and a lower next limit.
- Waivers and debt are fingerprinted current state, not historical ledgers.
- Delete resolved records; do not extend dates or limits merely to pass the
checker.
- `governance/testing/policy.toml` defines Python and frontend test-layout and
reliability discovery.
- Every test-governance candidate requires an exact classification or
debt-remediation disposition.
- Run both governance checkers after changing authored structure, test
placement, isolation, timing, resources, or reviewed state.
## ComfyUI Node Rules
- Public node identifiers are compatibility-sensitive.
+71
View File
@@ -1,3 +1,74 @@
## [1.11.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.0...v1.11.1) (2026-09-25)
### Bug Fixes
* **release:** attribute automation to Daisy ([2ae545d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/2ae545d64d70a454f635ee647fb7e6a1c3500b9b))
# [1.11.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.1...v1.11.0) (2026-09-25)
### Features
* **sampling:** make negative conditioning optional ([0bc81dc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0bc81dc4d00a13d42c65440da2058e94a505e8d5))
## [1.10.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.0...v1.10.1) (2026-09-24)
### Bug Fixes
* **ci:** expose test support to compatibility jobs ([0517f71](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0517f71da891414e9773c5cd47879ff748492e70))
* **governance:** enforce SugarSubstitute quality standards ([bbaed2c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/bbaed2c90656cd4e65e5bfcd4c0451f90ec2d7c7))
# [1.10.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.3...v1.10.0) (2026-09-21)
### Features
* **loaders:** add Krea 2 model loader ([166f029](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/166f029d12e5fce019133cc38be7279d05a5ecbc))
## [1.9.3](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.2...v1.9.3) (2026-09-20)
### Bug Fixes
* **attention-coupling:** restore regional LoRA sampling ([0255a0f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0255a0f5044278f14452b6b2582ec6646083f756))
## [1.9.2](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.1...v1.9.2) (2026-09-20)
### Bug Fixes
* **registry:** remove flagged package content ([f3a53b5](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f3a53b5ec6c080d98f7e9599cf55e849cf338021))
## [1.9.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.0...v1.9.1) (2026-09-20)
### Bug Fixes
* **contextual-diffusion:** project reference latents into views ([4cd780a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4cd780a2451aa472ce826834e4426b65693c46e8))
# [1.9.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.8.0...v1.9.0) (2026-09-19)
### Features
* **prompts:** add automatic NegPiP support ([6d052e9](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6d052e9800985696972bf431fa7dae4972a56313))
# [1.8.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.7.1...v1.8.0) (2026-09-19)
### Bug Fixes
* **downloads:** keep unknown sizes indeterminate ([a31467c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a31467cd3a4299e9e3929d44281018dba0322b3e))
* **models:** hide installed catalog choices ([d887e87](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d887e87e03eb6d0d9d5325fe43b67fbd9e47cf71))
### Features
* **models:** add curated ultralytics downloads ([b907fa2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b907fa2a17afa30170bc22cf1134a750241e55c2))
* **models:** prioritize installed ultralytics choices ([d30e04f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d30e04f229366d3e1d2388bd4d713b2b47a12a1f))
## [1.7.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.7.0...v1.7.1) (2026-09-11)
+6 -3
View File
@@ -12,7 +12,7 @@ The pack now covers model loading, regional prompting and segmentation, high-res
## Highlights
- Loaders that keep checkpoints, Anima, FLUX.1, and FLUX.2 models together with the text encoders, VAE, precision, and device choices they need.
- Loaders that keep checkpoints, Anima, FLUX.1, FLUX.2, and Krea 2 models together with the text encoders, VAE, precision, and device choices they need.
- My original Contextual Diffusion method for coherent high-resolution edits, plus MultiDiffusion and Mixture of Diffusers tiled sampling.
- Impact-compatible SEGS detection, segmentation, interactive preview, batching, and detailers.
- ADetailer-style `[SEP]` prompt batches, masked conditioning, and regional samplers, with optional Prompt Control scheduling and LoRA hooks.
@@ -74,7 +74,9 @@ Loading a checkpoint used to feel like choosing one file. Newer model families c
**Simple Load FLUX** handles FLUX.1 with CLIP-L, T5-XXL, and its VAE. **Simple Load FLUX.2** inspects the selected diffusion model and chooses the matching text encoder family for FLUX.2 dev, Klein 4B, or Klein 9B/KV conditioning. Both loaders can find or download their known text encoders and VAEs with visible Comfy progress.
The FLUX loaders only download those revision-locked, checksum-pinned support files. You still install and select the diffusion model. They also expose manual component selection, diffusion weight precision, and text-encoder device placement. Moving text encoding to the CPU can save VRAM, although it will take longer.
**Simple Load Krea 2** validates the selected Raw or Turbo diffusion model and loads the required Qwen3-VL 4B encoder with Krea's layered conditioning plus the Qwen Image VAE. Auto uses the official FP8-scaled encoder; the advanced encoder choice can download either the checksum-pinned FP8-scaled or BF16 file.
The FLUX and Krea 2 loaders only download those revision-locked, checksum-pinned support files. You still install and select the diffusion model. They also expose manual component selection, diffusion weight precision, and text-encoder device placement. Moving text encoding to the CPU can save VRAM, although it will take longer.
## Large images and high-resolution edits
@@ -164,7 +166,7 @@ SimpleSyrup adds three ComfyUI settings:
- **SimpleSyrup: External LLM endpoint** stores the OpenAI-compatible base URL used to discover provider models and run the external prompt nodes.
- **SimpleSyrup: External LLM API key** stores the provider key in OS credential storage.
With downloadable models enabled, selecting a known missing catalog entry lets its loader download the required files. With the setting disabled, the dropdowns contain models SimpleSyrup can verify locally. Anima, FLUX.1, and FLUX.2 support components are resolved by their own loaders and use checksum-pinned automatic choices.
With downloadable models enabled, selecting a known missing catalog entry lets its loader download the required files. With the setting disabled, the dropdowns contain models SimpleSyrup can verify locally. Anima, FLUX.1, FLUX.2, and Krea 2 support components are resolved by their own loaders and use checksum-pinned automatic choices. Automatic resolution checks cached and official paths first, then recognizes renamed files with matching size and checksum inside the appropriate ComfyUI model category. Known local support files are represented by their automatic choice instead of appearing again as manual dropdown entries.
Saving the external LLM endpoint and API key refreshes the provider models available in connected SimpleSyrup nodes. Image inputs require a provider model with vision support.
@@ -189,6 +191,7 @@ SimpleSyrup owes a lot to other projects:
- [ComfyUI Layer Style Advance](https://github.com/chflame163/ComfyUI_LayerStyle_Advance) provides the SAM model bundle SimpleSyrup can adapt.
- [Tiled Diffusion & VAE for AUTOMATIC1111](https://github.com/pkuliyi2015/multidiffusion-upscaler-for-automatic1111) informed the practical tiled diffusion and Mixture of Diffusers behavior reimplemented here.
- [RES4LYF](https://github.com/ClownsharkBatwing/RES4LYF) is the source of the beta57 scheduler preset reimplemented here.
- [ComfyUI-ppm](https://github.com/pamparamm/ComfyUI-ppm) by pamparamm provides the ModelPatcher-based NegPiP behavior adapted here and builds on the [ComfyUI port](https://github.com/laksjdjf/cd-tuner_negpip-ComfyUI) by laksjdjf and the [original WebUI implementation](https://github.com/hako-mikan/sd-webui-negpip) by hako-mikan.
SimpleSyrup also vendors or reimplements selected third-party behavior for SAM-HQ, MobileSAM, GroundingDINO, AUTOMATIC1111 sampler behavior, k-diffusion, and tiled diffusion. See [third_party/NOTICE.md](third_party/NOTICE.md) for the complete notices.
+12 -10
View File
@@ -12,22 +12,24 @@ from . import simple_syrup as _simple_syrup_package
sys.modules.setdefault("simple_syrup", _simple_syrup_package)
from .simple_syrup.integration.external_llm_routes import ( # noqa: E402
register_external_llm_routes,
)
from .simple_syrup.integration.mask_batch_preview_routes import ( # noqa: E402
register_mask_batch_preview_routes,
)
from .simple_syrup.integration.quant_cache_routes import ( # noqa: E402
register_quant_cache_routes,
)
from .simple_syrup.integration.settings_routes import ( # noqa: E402
register_settings_routes,
)
from .simple_syrup.runtime.attention_region_prompt_handler import ( # noqa: E402
register_attention_region_prompt_handler,
)
from .simple_syrup.runtime.comfy_safetensors_dtypes import ( # noqa: E402
register_comfy_safetensors_dtypes,
)
from .simple_syrup.runtime.external_llm_routes import ( # noqa: E402
register_external_llm_routes,
)
from .simple_syrup.runtime.mask_batch_preview_routes import ( # noqa: E402
register_mask_batch_preview_routes,
)
from .simple_syrup.runtime.quant_cache_routes import ( # noqa: E402
register_quant_cache_routes,
)
from .simple_syrup.runtime.settings_routes import register_settings_routes # noqa: E402
WEB_DIRECTORY = "./web/dist"
+2
View File
@@ -0,0 +1,2 @@
schema_version = 1
debts = []
+1
View File
@@ -0,0 +1 @@
schema_version = 1
+41
View File
@@ -0,0 +1,41 @@
schema_version = 2
[structure]
soft_lines = 350
hard_lines = 500
source_roots = [
"simple_syrup/domain",
"simple_syrup/image",
"simple_syrup/integration",
"simple_syrup/masking",
"simple_syrup/nodes",
"simple_syrup/nodes_v3",
"simple_syrup/runtime",
"simple_syrup/services",
"simple_syrup/shared",
"tests",
"tools",
"scripts",
"web/src",
"web/tests",
]
source_files = [
"__init__.py",
".releaserc.cjs",
"eslint.config.js",
"simple_syrup/__init__.py",
"vitest.config.ts",
]
source_extensions = [
".cjs",
".js",
".mjs",
".py",
".pyi",
".ts",
]
excluded_paths = []
[registries]
debt = "governance/architecture/debt.toml"
waivers = "governance/architecture/waivers.toml"
+59
View File
@@ -0,0 +1,59 @@
schema_version = 1
review_by = 2027-03-31
fingerprint = "sha256:4b78e0caf8bf4f90d5a7a46ff32b28b01c94c034efc3ddbc24cfa16b8e132ea0"
cohesive_paths = [
"simple_syrup/masking/prompt_segs_with_sam_service.py",
"simple_syrup/nodes/prompt_segs_with_sam.py",
"simple_syrup/nodes_v3/legacy_node_wrappers.py",
"simple_syrup/runtime/attention_region_affinity.py",
"simple_syrup/runtime/attention_region_capture.py",
"simple_syrup/runtime/attention_sampler_lineage.py",
"simple_syrup/runtime/regional_lora/anima_module_surface.py",
"simple_syrup/runtime/spatial_model_arguments.py",
"simple_syrup/services/concept_attention_evidence.py",
"tests/comfy_integration/test_comfy_regional_adapter_resolver.py",
"tests/comfy_integration/test_comfy_regional_conditioning_processing.py",
"tests/models/loading/test_checkpoint_quantizer.py",
"tests/models/patching/test_model_patcher_mutations.py",
"tests/prompting/prompt_control/test_prompt_control_schedule_encode_graph.py",
"tests/regional_generation/anima/test_anima_activation_context.py",
"tests/regional_generation/anima/test_anima_full_tile_lora_equivalence.py",
"tests/regional_generation/anima/test_anima_loader.py",
"tests/regional_generation/anima/test_anima_multi_lora_composition.py",
"tests/regional_generation/anima/test_anima_regional_diagnostics.py",
"tests/regional_generation/anima/test_anima_regional_diagnostics_wrapper.py",
"tests/regional_generation/anima/test_anima_regional_permutation_diagnostics.py",
"tests/regional_generation/anima/test_anima_single_adapter_mutations.py",
"tests/regional_generation/attention_coupling/test_attention_coupling_model_preparation_service.py",
"tests/regional_generation/attention_regions/test_attention_region_capture.py",
"tests/regional_generation/attention_regions/test_attention_region_completion.py",
"tests/regional_generation/attention_regions/test_attention_region_components.py",
"tests/regional_generation/attention_regions/test_attention_region_geometry.py",
"tests/regional_generation/regional/test_regional_attention_batching.py",
"tests/regional_generation/regional/test_regional_linear_execution.py",
"tests/regional_generation/regional/test_regional_model_patch_interop.py",
"tests/regional_generation/regional/test_regional_multidiffusion_sampling.py",
"tests/regional_generation/spatial/test_contextual_model_wrapper.py",
"tests/sampling/test_multidiffusion_sampling.py",
"tests/sampling/test_sampling_scheduler_references.py",
"tests/sampling/test_sampling_schedulers.py",
"tests/segmentation/detection/test_ultralytics_loader.py",
"tests/segmentation/segs/test_detail_segs_as_regions_service.py",
"tests/segmentation/segs/test_prompt_segs_with_sam_node.py",
"tools/architecture_governance/validation.py",
"tools/attention_coupling_benchmark/comfy_probe/negpip_runtime.py",
"tools/negpip_integration/run.py",
"tools/prompt_control_attention_coupling_integration/validation.py",
"tools/run_global_prompt_lora_proof.py",
"tools/test_governance/semantic_patterns.py",
"tools/test_governance/validation.py",
"web/src/orderedMediaNode.ts",
"web/src/orderedMediaPreviewActions.ts",
"web/tests/media/orderedMediaPreviewActions.test.ts",
]
debt_paths = [
]
remediations = []
+78
View File
@@ -0,0 +1,78 @@
schema_version = 1
[[waivers]]
id = "SSY-WAIVER-S001"
owner = "model catalog"
rule = "STRUCT003"
path = "simple_syrup/runtime/model_catalog.py"
kind = "structural"
justification = "This module is the single immutable catalog authority for supported model families and artifacts. Most of its size is declarative checksums, repository identities, filenames, and URLs; its small query surface and entry constructors enforce one catalog schema and change with that same metadata contract. Splitting entries by provider would scatter uniqueness and lookup review without separating behavior or ownership."
issue = "chore:SSY-WAIVER-S001"
review_by = 2027-03-31
max_lines = 687
[[waivers]]
id = "SSY-WAIVER-S002"
owner = "sampling scheduler policy"
rule = "STRUCT003"
path = "simple_syrup/runtime/sampling_schedulers.py"
kind = "structural"
justification = "This module owns the complete sigma-schedule policy exposed to every sampler: supported names, fixed published AYS/GITS tables, Comfy delegation, local schedule calculation, denoise truncation, and sampler-specific terminal handling. Roughly half the file is immutable numeric reference data, while the executable functions share one public calculation boundary and dependency direction."
issue = "chore:SSY-WAIVER-S002"
review_by = 2027-03-31
max_lines = 588
[[waivers]]
id = "SSY-WAIVER-S003"
owner = "Anima cross-attention patch contracts"
rule = "STRUCT003"
path = "tests/regional_generation/anima/test_anima_cross_attention.py"
kind = "structural"
justification = "This module is one integration contract for AnimaRegionalCrossAttentionPatch: it installs the exact Anima module surface, supplies one deterministic attention double, drives branch/mask/context alignment, verifies failure restoration, and proves all 28 clone-local patches. The sizable builders encode a single valid execution context and are not independent production responsibilities."
issue = "chore:SSY-WAIVER-S003"
review_by = 2027-03-31
max_lines = 661
[[waivers]]
id = "SSY-WAIVER-S004"
owner = "Anima multi-LoRA fidelity contracts"
rule = "STRUCT003"
path = "tests/regional_generation/anima/test_anima_multi_lora_fidelity.py"
kind = "structural"
justification = "This module owns one numerical fidelity matrix for ordered multi-LoRA composition across schedules, branches, regions, target families, and the complete Anima surface. Its execution and reference helpers intentionally remain adjacent so every permutation is compared through the same independently calculated oracle; splitting by scenario would duplicate or conceal that shared proof authority."
issue = "chore:SSY-WAIVER-S004"
review_by = 2027-03-31
max_lines = 678
[[waivers]]
id = "SSY-WAIVER-S005"
owner = "regional convolution execution contracts"
rule = "STRUCT003"
path = "tests/regional_generation/regional/test_regional_convolution_execution.py"
kind = "structural"
justification = "This module is the complete numerical contract for RegionalConvolutionExecutor across direct, pointwise, LoCon, strided, grouped, tiled-batch, ordered-adapter, and low-precision execution. Its fixture builds the same execution plan and independent convolution reference for every case, so the tests share one owner, oracle, dependency surface, and change cadence."
issue = "chore:SSY-WAIVER-S005"
review_by = 2027-03-31
max_lines = 556
[[waivers]]
id = "SSY-WAIVER-S006"
owner = "Ultralytics detection node contracts"
rule = "STRUCT003"
path = "tests/segmentation/detection/test_detect_segs_with_ultralytics_node.py"
kind = "structural"
justification = "This module owns the workflow-facing contract of one Comfy node, including its schema, exact input order, batch behavior, sorting/ranking limits, union mode, and output shape. The service and builder doubles are deliberately local representations of that node boundary; every test changes with the same node API and persisted workflow contract."
issue = "chore:SSY-WAIVER-S006"
review_by = 2027-03-31
max_lines = 592
[[waivers]]
id = "SSY-WAIVER-S007"
owner = "scale-factor detail service contracts"
rule = "STRUCT003"
path = "tests/segmentation/segs/test_detail_segs_by_scale_factor_service.py"
kind = "structural"
justification = "This module is the end-to-end behavioral contract for DetailSEGSByScaleFactorService, whose single orchestration transaction selects per-segment conditioning, sizes and resizes crops, applies masks, samples, decodes, and pastes results. Its sampler and resizer doubles record that one transaction; splitting them would duplicate setup without creating a distinct behavior owner."
issue = "chore:SSY-WAIVER-S007"
review_by = 2027-03-31
max_lines = 532
+2
View File
@@ -0,0 +1,2 @@
schema_version = 1
debts = []
+27
View File
@@ -0,0 +1,27 @@
schema_version = 1
[scope]
test_root = "tests"
semantic_support_roots = ["tools"]
root_source_extensions = [".py", ".pyi"]
allowed_root_source_paths = [
"tests/ci_test_policy.py",
"tests/conftest.py",
]
[discovery]
serial_policy = "tests/ci_test_policy.py"
wait_calls = ["QTest.qWait", "time.sleep"]
wall_clock_calls = [
"QElapsedTimer",
"monotonic",
"perf_counter",
"time.monotonic",
"time.perf_counter",
]
xdist_environment_name = "PYTEST_XDIST_WORKER"
repository_scratch_name = ".pytest-tmp"
[registries]
debt = "governance/testing/debt.toml"
waivers = "governance/testing/waivers.toml"
+153
View File
@@ -0,0 +1,153 @@
schema_version = 1
[[waivers]]
id = "SSY-TEST-WAIVER-C001"
owner = "pytest CUDA isolation bootstrap"
kind = "classification"
disposition = "framework_infrastructure"
rule = "ENV001"
candidates = ["ENV001|tests/conftest.py|<module>:environment-mutation:1"]
paths = ["tests/conftest.py"]
fingerprint = "sha256:481069f16237eff312f817f1e4dd3213e804eee0f3341e3f6c4aa2ba73066af3"
rationale = "The root pytest bootstrap disables CUDA visibility before Torch and ComfyUI are imported unless the maintainer explicitly enables hardware tests. Every xdist worker receives the same inherited setting before collection, so this is suite framework configuration rather than mutable test-owned state."
issue = "chore:SSY-TEST-WAIVER-C001"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C002"
owner = "native checkpoint quantization proofs"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = [
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:1",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:2",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:3",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:4",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:5",
]
paths = ["tests/models/loading/test_checkpoint_quantizer.py"]
fingerprint = "sha256:bc2bee06ddb6cf41ba66497855e7349e5aa5dd5292f732284a812a615fa65c49"
rationale = "These proofs exercise installed ComfyUI NVFP4/MXFP8 kernels, GPU compute capability, and optional comfy-aimdo reload behavior. CPU fake-boundary tests in the same module always run; only the native serialization contracts are skipped when their external runtime or hardware capability does not exist."
issue = "chore:SSY-TEST-WAIVER-C002"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C003"
owner = "CUDA Anima projection precision proof"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_projection_batch.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/anima/test_anima_projection_batch.py"]
fingerprint = "sha256:cb5e280524450a58144b350799129b3c27a6d4ce430867de582da19303987c33"
rationale = "This exact comparison bounds BF16 batched projection error on CUDA tensors against independently generated projections. Its behavior depends on the installed CUDA execution path and cannot truthfully be substituted by CPU arithmetic; all device-independent projection contracts remain mandatory."
issue = "chore:SSY-TEST-WAIVER-C003"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C004"
owner = "native Anima quantization workflow"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_quantization_workflow.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/anima/test_anima_quantization_workflow.py"]
fingerprint = "sha256:ff02416ea8c16112605b221975425a6f88013f66e2a8bc66bdc2d1a0ecfa5053"
rationale = "The workflow proof intentionally uses the installed ComfyUI NVFP4 implementation and the active GPU's native compute support before loading the generated Anima artifact. It remains optional only where that hardware capability is absent; the portable resolver and policy tests still run everywhere."
issue = "chore:SSY-TEST-WAIVER-C004"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C005"
owner = "installed Anima CUDA smoke"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_regional_model_smoke.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/anima/test_anima_regional_model_smoke.py"]
fingerprint = "sha256:73b3bde9d6009b71608aec73dde862d963a3a723d5ad5d57c130428a4c1511c6"
rationale = "This smoke test constructs the installed Comfy Anima model and executes its complete patched forward on CUDA tensors. It proves the native device/runtime integration and is skipped only without CUDA; deterministic component and surface contracts cover the same code boundaries on every host."
issue = "chore:SSY-TEST-WAIVER-C005"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C006"
owner = "regional convolution CUDA precision"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_convolution_execution.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/regional/test_regional_convolution_execution.py"]
fingerprint = "sha256:f197de881034f1c11b46ce290f3b6c515fe251d5bdef7efb419f43ad7257bf40"
rationale = "The optional parameterized cases prove FP16 and BF16 regional convolution behavior through the installed CUDA kernels. CPU tests in the same contract cover dimensions, grouping, stride, masking, ordering, and reference math; only device-specific low-precision execution requires CUDA."
issue = "chore:SSY-TEST-WAIVER-C006"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C007"
owner = "regional linear CUDA precision"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = [
"OPTIONAL001|tests/regional_generation/regional/test_regional_linear_execution.py|<module>:optional-proof:1",
"OPTIONAL001|tests/regional_generation/regional/test_regional_linear_execution.py|<module>:optional-proof:2",
]
paths = ["tests/regional_generation/regional/test_regional_linear_execution.py"]
fingerprint = "sha256:d8a833ef160838b80db21d7240d789879deb8a4dc39f89d52145b1ebf580f765"
rationale = "These cases validate installed CUDA FP16/BF16 projection rounding and compatible-adapter accumulation on the actual device execution path. The module's CPU contracts always prove masking, ordering, preparation, and reference deltas; the classified cases add hardware-specific numerical evidence."
issue = "chore:SSY-TEST-WAIVER-C007"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C008"
owner = "fused regional LoRA CUDA kernel"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_lora_fused_active_accumulation.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/regional/test_regional_lora_fused_active_accumulation.py"]
fingerprint = "sha256:2053ff281c4e707c99db6424074ef3526c901ad84f0f750e044c35116981cd9c"
rationale = "The entire module qualifies the CUDA-only fused regional LoRA accumulator across low-precision dtypes, adapter counts, and indexed paths. There is no CPU implementation to exercise, while the non-fused accumulation owner has mandatory portable reference coverage."
issue = "chore:SSY-TEST-WAIVER-C008"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C009"
owner = "fused multiplier CUDA transport"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_lora_fused_multiplier_transport.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/regional/test_regional_lora_fused_multiplier_transport.py"]
fingerprint = "sha256:56ef4217551bdccc97fc6277bd2ade11ac10b9512ee3725c46b210044f380f21"
rationale = "This module proves multiple adapter multipliers reach the CUDA fused kernel without an intermediate stack. The production behavior exists only for a CUDA-capable device, and portable composition tests independently cover ordering and multiplier semantics outside this native optimization."
issue = "chore:SSY-TEST-WAIVER-C009"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C010"
owner = "ordered tensor CUDA accumulation"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/sampling/test_ordered_tensor_accumulation.py|<module>:optional-proof:1"]
paths = ["tests/sampling/test_ordered_tensor_accumulation.py"]
fingerprint = "sha256:b6306323fd555706f0b7525078acfa199c657f47f96432ce320da9057fc41709"
rationale = "The parameter matrix compares stepwise CUDA accumulation and its exact low-precision rounding across one and multiple Triton launches. Mandatory CPU contracts prove ordered in-place accumulation; only the GPU kernel and device dtypes are capability-gated."
issue = "chore:SSY-TEST-WAIVER-C010"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C011"
owner = "managed Windows Comfy process lifetime"
kind = "classification"
disposition = "platform_native"
rule = "PROCESS001"
candidates = ["PROCESS001|tools/comfy_integration/server_process.py|<module>:unscoped-child-process:1"]
paths = ["tools/comfy_integration/server_process.py"]
fingerprint = "sha256:6892815fbffe96d59bbb3f9069e44bfcdb9d6ea39e3f17f844695408af4233c0"
rationale = "WindowsComfyProcess intentionally transfers the created Popen and log handles into an explicit long-lived owner because the integration run must use the server after start returns. Its stop method signals the exact process group, bounds both graceful and forced waits, retries bounded taskkill calls, and closes both logs in finally."
issue = "chore:SSY-TEST-WAIVER-C011"
review_by = 2027-03-31
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "simple-syrup-comfyui",
"version": "1.7.1",
"version": "1.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simple-syrup-comfyui",
"version": "1.7.1",
"version": "1.11.1",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@eslint/js": "^9.39.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "simple-syrup-comfyui",
"version": "1.7.1",
"version": "1.11.1",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+2 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "SimpleSyrup"
description = "Workflow-focused ComfyUI extensions for image generation."
version = "1.7.1"
version = "1.11.1"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
requires-python = ">=3.11"
@@ -69,6 +69,7 @@ ignore_missing_imports = true
[tool.pytest.ini_options]
pythonpath = [".", "../.."]
testpaths = ["tests"]
addopts = ["--strict-markers", "--import-mode=importlib"]
markers = [
"external_artifact: requires a locally installed external source or generated benchmark artifact",
]
+1 -1
View File
@@ -6,6 +6,6 @@
from __future__ import annotations
__version__ = "1.7.1"
__version__ = "1.11.1"
__all__: list[str] = ["__version__"]
@@ -0,0 +1,20 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define validated Attention Coupling preparation data."""
from __future__ import annotations
from dataclasses import dataclass
from .raw_regional_attention import RawRegionalAttentionPlan
@dataclass(frozen=True, slots=True)
class AttentionCouplingPreparation:
"""Retain the full raw plan and base-only ordinary sampler inputs."""
plan: RawRegionalAttentionPlan
positive: object
negative: object
@@ -0,0 +1,89 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Detect effective negative weights in Comfy-style prompt emphasis."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class _WeightedPromptSegment:
"""Retain one parsed prompt fragment and its effective scalar weight."""
text: str
weight: float
def contains_negative_prompt_weight(text: str) -> bool:
"""Return whether valid nested emphasis gives any prompt text a negative weight."""
if not isinstance(text, str):
raise TypeError("Negative prompt-weight detection requires text.")
escaped = text.replace(r"\)", "\0\1").replace(r"\(", "\0\2")
return any(
segment.text and segment.weight < 0.0
for segment in _weighted_segments(escaped, 1.0)
)
def _weighted_segments(
text: str,
current_weight: float,
) -> tuple[_WeightedPromptSegment, ...]:
"""Parse emphasis with the same nesting and final-colon rules as ComfyUI."""
parsed: list[_WeightedPromptSegment] = []
for item in _parenthesized_items(text):
weight = current_weight
if len(item) >= 2 and item[0] == "(" and item[-1] == ")":
inner = item[1:-1]
delimiter = inner.rfind(":")
weight *= 1.1
if delimiter > 0:
try:
weight = float(inner[delimiter + 1 :])
except ValueError:
pass
else:
inner = inner[:delimiter]
parsed.extend(_weighted_segments(inner, weight))
continue
parsed.append(
_WeightedPromptSegment(
item.replace("\0\1", ")").replace("\0\2", "("),
current_weight,
)
)
return tuple(parsed)
def _parenthesized_items(text: str) -> tuple[str, ...]:
"""Split top-level parenthesized regions while preserving malformed input."""
result: list[str] = []
current = ""
nesting = 0
for character in text:
if character == "(":
if nesting == 0:
if current:
result.append(current)
current = "("
else:
current += character
nesting += 1
elif character == ")":
nesting -= 1
if nesting == 0:
result.append(f"{current})")
current = ""
else:
current += character
else:
current += character
if current:
result.append(current)
return tuple(result)
+51
View File
@@ -0,0 +1,51 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define renderer-neutral SEG preview documents."""
from __future__ import annotations
from dataclasses import dataclass
import torch
from .segs import CropRegion
@dataclass(frozen=True)
class AtlasPlacement:
"""Locate one region mask inside the packed mask atlas."""
left: int
top: int
width: int
height: int
@dataclass(frozen=True)
class SegPreviewRegion:
"""Describe one interactive region and its packed mask geometry."""
region_id: str
index: int
label: str
confidence: float
active_area: int
color: str
crop: CropRegion
atlas: AtlasPlacement
@dataclass(frozen=True)
class SegPreviewDocument:
"""Carry bounded image assets and interaction metadata to a UI adapter."""
source_width: int
source_height: int
preview_width: int
preview_height: int
image: torch.Tensor
atlas: torch.Tensor
region_images: tuple[torch.Tensor, ...]
regions: tuple[SegPreviewRegion, ...]
@@ -9,7 +9,7 @@ from __future__ import annotations
import torch
import torch.nn.functional as F
from ..domain.segs import BoundingBox, CropRegion
from .segs import BoundingBox, CropRegion
def validate_single_image(image: object, operation: str) -> torch.Tensor:
+5 -5
View File
@@ -11,11 +11,6 @@ from dataclasses import dataclass
import torch
from ..masking.segs_mask_ops import (
crop_region_for_bbox,
resize_mask,
validate_single_image,
)
from ..shared.logging import get_logger
from .segs import (
BoundingBox,
@@ -23,6 +18,11 @@ from .segs import (
NativeSegs,
Segment,
)
from .segs_mask_ops import (
crop_region_for_bbox,
resize_mask,
validate_single_image,
)
LOGGER = get_logger(__name__)
+5
View File
@@ -0,0 +1,5 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own inbound ComfyUI integration and transport composition."""
@@ -14,9 +14,9 @@ from typing import Any, Protocol, cast
from aiohttp import web
from ..domain.external_llm import ExternalLLMConfigError, ExternalLLMProviderError
from ..runtime.external_llm_keyring import ExternalLLMKeyringError
from ..services.external_llm_prompt_service import ExternalLLMPromptService
from ..shared.logging import get_logger
from .external_llm_keyring import ExternalLLMKeyringError
LOGGER = get_logger(__name__)
EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"
@@ -12,13 +12,13 @@ from typing import Any, Protocol, cast
from aiohttp import web
from ..runtime.quant_cache_settings import SettingsQuantCacheLimitProvider
from ..services.quant_cache_service import (
QuantCacheEvictionResult,
QuantCacheService,
QuantCacheStatus,
)
from ..services.quantized_model_boundaries import QuantCacheLimitProvider
from .quant_cache_settings import SettingsQuantCacheLimitProvider
QUANT_CACHE_ROUTE = "/simple-syrup/quant-cache"
Handler = Callable[[Any], Coroutine[Any, Any, web.Response]]
@@ -12,12 +12,12 @@ from typing import Any, Protocol, cast
from aiohttp import web
from ..shared.logging import get_logger
from .settings import (
from ..runtime.settings import (
SimpleSyrupSettings,
SimpleSyrupSettingsError,
)
from .settings_repository import SimpleSyrupSettingsRepository
from ..runtime.settings_repository import SimpleSyrupSettingsRepository
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
SETTINGS_ROUTE = "/simple-syrup/settings"
@@ -15,8 +15,7 @@ from ..domain.segs import (
NativeSegs,
Segment,
)
from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask
from ..masking.segs_mask_ops import (
from ..domain.segs_mask_ops import (
crop_image,
crop_mask,
crop_region_for_bbox,
@@ -24,6 +23,7 @@ from ..masking.segs_mask_ops import (
normalize_mask,
validate_single_image,
)
from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask
from ..runtime.sam_segmenter import SAMBoxSegmenter, SAMModelSegmenter
from ..runtime.text_box_detector import (
GroundingDINOTextBoxDetector,
@@ -21,8 +21,8 @@ from ..domain.regional_detailing import (
SegmentConditioningPair,
)
from ..domain.segs import CropRegion
from ..domain.segs_mask_ops import feather_mask, resize_mask
from .detailer_masks import gaussian_feather_mask
from .segs_mask_ops import feather_mask, resize_mask
OPERATION = "Detail SEGS as Regions"
+25 -23
View File
@@ -64,10 +64,6 @@ class DetailSEGSAsRegions:
"image": ("IMAGE", {"tooltip": tooltips.DETAIL_IMAGE}),
"model": ("MODEL", {"tooltip": tooltips.DETAIL_MODEL}),
"vae": ("VAE", {"tooltip": tooltips.DETAIL_VAE}),
"negative": (
"CONDITIONING",
{"tooltip": tooltips.REGIONAL_GLOBAL_NEGATIVE},
),
"positive": (
"CONDITIONING",
{"tooltip": tooltips.REGIONAL_GLOBAL_POSITIVE},
@@ -187,7 +183,13 @@ class DetailSEGSAsRegions:
"tooltip": tooltips.DETAIL_TILED_DECODE,
},
),
}
},
"optional": {
"negative": (
"CONDITIONING",
{"tooltip": tooltips.REGIONAL_GLOBAL_NEGATIVE},
),
},
}
def detail(
@@ -195,24 +197,24 @@ class DetailSEGSAsRegions:
image: object,
model: Any,
vae: Any,
negative: Any,
positive: Any,
segs: object,
region_positive: object,
global_prompt_weight: object,
scale_factor: object,
upscale_method: object,
seed: object,
steps: object,
cfg: object,
sampler_name: object,
scheduler: object,
denoise: object,
feather: object,
noise_mask: object,
noise_mask_feather: object,
tiled_encode: object,
tiled_decode: object,
negative: Any | None = None,
positive: Any = None,
segs: object = None,
region_positive: object = None,
global_prompt_weight: object = 0.25,
scale_factor: object = 1.0,
upscale_method: object = "lanczos",
seed: object = 0,
steps: object = 20,
cfg: object = 8.0,
sampler_name: object = "euler",
scheduler: object = "normal",
denoise: object = 0.5,
feather: object = 5,
noise_mask: object = True,
noise_mask_feather: object = 20,
tiled_encode: object = False,
tiled_decode: object = False,
) -> tuple[object]:
"""Run regional detailing and return the detailed image."""
@@ -61,10 +61,6 @@ class DetailSEGSByScaleFactor:
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_POSITIVE},
),
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
"scale_factor": (
"FLOAT",
scale_factor_options(default=1.5),
@@ -171,7 +167,13 @@ class DetailSEGSByScaleFactor:
"tooltip": tooltips.DETAIL_TILED_DECODE,
},
),
}
},
"optional": {
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
},
}
def detail(
@@ -181,21 +183,21 @@ class DetailSEGSByScaleFactor:
model: Any,
vae: Any,
positive: Any,
negative: Any,
scale_factor: object,
upscale_method: object,
clamp_size: object,
seed: object,
steps: object,
cfg: object,
sampler_name: object,
scheduler: object,
denoise: object,
feather: object,
noise_mask: object,
noise_mask_feather: object,
tiled_encode: object,
tiled_decode: object,
negative: Any | None = None,
scale_factor: object = 1.5,
upscale_method: object = "lanczos",
clamp_size: object = 0,
seed: object = 0,
steps: object = 20,
cfg: object = 8.0,
sampler_name: object = "euler",
scheduler: object = "normal",
denoise: object = 0.5,
feather: object = 5,
noise_mask: object = True,
noise_mask_feather: object = 20,
tiled_encode: object = False,
tiled_decode: object = False,
) -> tuple[object]:
"""Run scale-factor detailing and return the detailed image."""
@@ -70,10 +70,6 @@ class DetailSEGSByScaleFactorTiledDiffusion:
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_POSITIVE},
),
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
"scale_factor": (
"FLOAT",
scale_factor_options(default=1.5),
@@ -231,7 +227,13 @@ class DetailSEGSByScaleFactorTiledDiffusion:
"tooltip": tooltips.LATENT_TILE_BATCH_SIZE,
},
),
}
},
"optional": {
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
},
}
def detail(
@@ -241,26 +243,26 @@ class DetailSEGSByScaleFactorTiledDiffusion:
model: Any,
vae: Any,
positive: Any,
negative: Any,
scale_factor: object,
upscale_method: object,
clamp_size: object,
seed: object,
steps: object,
cfg: object,
sampler_name: object,
scheduler: object,
denoise: object,
feather: object,
noise_mask: object,
noise_mask_feather: object,
tiled_encode: object,
tiled_decode: object,
diffusion_mode: object,
latent_tile_width: object,
latent_tile_height: object,
latent_tile_overlap: object,
latent_tile_batch_size: object,
negative: Any | None = None,
scale_factor: object = 1.5,
upscale_method: object = "lanczos",
clamp_size: object = 0,
seed: object = 0,
steps: object = 20,
cfg: object = 8.0,
sampler_name: object = "euler",
scheduler: object = "normal",
denoise: object = 0.5,
feather: object = 5,
noise_mask: object = True,
noise_mask_feather: object = 20,
tiled_encode: object = False,
tiled_decode: object = False,
diffusion_mode: object = "multidiffusion",
latent_tile_width: object = 128,
latent_tile_height: object = 128,
latent_tile_overlap: object = 16,
latent_tile_batch_size: object = 4,
) -> tuple[object]:
"""Run tiled diffusion scale-factor detailing and return the image."""
@@ -12,7 +12,7 @@ import torch
from ..domain.conditioning_batch import ConditioningBatch
from ..domain.segs import NativeSegs
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
def image_inputs(image: object, operation_name: str) -> tuple[torch.Tensor, ...]:
@@ -16,8 +16,8 @@ from ..domain.segs import (
SORT_ORDER_OPTIONS,
NativeSegs,
)
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..runtime.ultralytics_loader import UltralyticsDetectorModel
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
from ..runtime.ultralytics_model_adapter import UltralyticsDetectorModel
from ..services.segs_detection_service import (
SegsDetectionService,
)
+16 -5
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any
from ..runtime.model_catalog import grounding_dino_choices, sam_choices
from ..runtime.model_choices import ModelChoiceService, default_choice
from ..runtime.model_metadata import GroundedSAMModelMetadata
from . import tooltips
@@ -17,6 +17,7 @@ class GroundedSAMModelInfo:
"""Expose selected grounded SAM source and local path metadata."""
_metadata = GroundedSAMModelMetadata()
_choices = ModelChoiceService()
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("model_info",)
@@ -31,19 +32,27 @@ class GroundedSAMModelInfo:
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
"""Declare deterministic model metadata inputs."""
sam_model_choices = cls._choices.sam_choices()
grounding_dino_model_choices = cls._choices.grounding_dino_choices()
return {
"required": {
"sam_model": (
sam_choices(),
sam_model_choices,
{
"default": "sam_hq_vit_b (379MB)",
"default": default_choice(
sam_model_choices,
"sam_hq_vit_b (379MB)",
),
"tooltip": tooltips.SAM_MODEL_INPUT,
},
),
"grounding_dino_model": (
grounding_dino_choices(),
grounding_dino_model_choices,
{
"default": "GroundingDINO_SwinT_OGC (694MB)",
"default": default_choice(
grounding_dino_model_choices,
"GroundingDINO_SwinT_OGC (694MB)",
),
"tooltip": tooltips.GROUNDING_DINO_MODEL_INPUT,
},
),
@@ -53,4 +62,6 @@ class GroundedSAMModelInfo:
def describe(self, sam_model: str, grounding_dino_model: str) -> tuple[str]:
"""Return JSON metadata for selected model entries."""
self._choices.reject_sentinel(sam_model)
self._choices.reject_sentinel(grounding_dino_model)
return (self._metadata.describe_selection(sam_model, grounding_dino_model),)
+11 -7
View File
@@ -78,10 +78,6 @@ class KSamplerExtras:
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.POSITIVE_CONDITIONING},
),
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.NEGATIVE_CONDITIONING},
),
"latent_image": ("LATENT", {"tooltip": tooltips.LATENT_IMAGE}),
"denoise": (
"FLOAT",
@@ -93,7 +89,13 @@ class KSamplerExtras:
"tooltip": tooltips.DENOISE_STRENGTH,
},
),
}
},
"optional": {
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.NEGATIVE_CONDITIONING},
),
},
}
def sample(
@@ -105,12 +107,14 @@ class KSamplerExtras:
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: Latent,
negative: Any | None = None,
latent_image: Latent | None = None,
denoise: float = 1.0,
) -> tuple[Latent]:
"""Sample a latent with ComfyUI samplers and extra scheduler sigmas."""
if latent_image is None:
raise TypeError("KSampler Extras requires a latent_image input.")
output = self.service_class().sample(
model=model,
seed=seed,
+8 -3
View File
@@ -8,7 +8,8 @@ from __future__ import annotations
from typing import Any, ClassVar
from ..runtime.ultralytics_loader import UltralyticsLoaderService
from ..runtime.model_downloads import ComfyProgressReporter
from ..services.ultralytics_loader_service import UltralyticsLoaderService
class LoadUltralyticsModel:
@@ -40,7 +41,8 @@ class LoadUltralyticsModel:
{
"default": choices[0],
"tooltip": (
"Ultralytics model file in the ComfyUI models folder."
"A local Ultralytics model or a curated model that "
"downloads to ComfyUI's Impact Pack-compatible folders."
),
},
)
@@ -50,5 +52,8 @@ class LoadUltralyticsModel:
def load(self, model_name: str) -> tuple[object, object, object]:
"""Load the selected detector and paired compatibility facades."""
loaded = self.service_class().load(model_name)
loaded = self.service_class().load(
model_name,
progress=ComfyProgressReporter(),
)
return loaded.detector_model, loaded.bbox_detector, loaded.segm_detector
+1 -1
View File
@@ -16,9 +16,9 @@ from ..domain.segs import (
SORT_ORDER_OPTIONS,
NativeSegs,
)
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
from ..masking.mask_ops import DETAIL_METHODS
from ..masking.prompt_segs_with_sam_service import PromptSEGSWithSAMService
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..services.segs_output_service import (
CombinedSegsResult,
build_combined_segs_result,
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any
from ..runtime.prompt_control_schedule_encode_graph import (
from ..services.prompt_control_schedule_encode_graph import (
PromptControlScheduleEncodeGraphBuilder,
)
@@ -74,20 +74,21 @@ class ScheduleAndEncodePromptsWithPromptControl:
),
},
),
},
"optional": {
"negative_prompt": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": (
"Negative Prompt-Control text; [SEP] or [SEP|name] "
"creates ordered entries, and global text fills missing "
"negative regions."
"Optional negative Prompt-Control text; [SEP] or "
"[SEP|name] creates ordered entries, and global text "
"fills missing negative regions. Leave disconnected "
"to encode an empty negative prompt."
),
},
),
},
"optional": {
"encode_style": (
"STRING",
{
@@ -107,7 +108,7 @@ class ScheduleAndEncodePromptsWithPromptControl:
model: Any,
clip: Any,
positive_prompt: str,
negative_prompt: str,
negative_prompt: str = "",
encode_style: str = "",
) -> Any:
"""Build lazy Prompt-Control graph expansion for prompts."""
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import Any, ClassVar
import torch
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
from ..runtime.progress import PhaseProgressReporter, create_comfy_phase_progress
from ..services.segs_from_sam_output_service import SEGSFromSAMOutputService
+15 -8
View File
@@ -11,10 +11,13 @@ from types import ModuleType
from typing import Any
from ..domain.anima_quantization import AnimaQuantizationRecipe
from ..runtime.anima_artifacts import ANIMA_QWEN_TEXT_ENCODER
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.quantization_capabilities import QuantizationCapabilityCatalog
from ..runtime.quantization_progress import ComfyQuantizationProgressReporter
from ..runtime.qwen_artifacts import QWEN_IMAGE_VAE
from ..runtime.vae_loader import vae_choices
from ..services.anima_loader_service import (
AUTO_CHOICE,
@@ -84,7 +87,12 @@ class SimpleLoadAnima:
},
),
"text_encoder": (
_choices_with_auto(folder_paths.get_filename_list("text_encoders")),
automatic_component_choices(
installed=folder_paths.get_filename_list("text_encoders"),
artifacts=(ANIMA_QWEN_TEXT_ENCODER,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
{
"default": AUTO_CHOICE,
"advanced": True,
@@ -106,7 +114,12 @@ class SimpleLoadAnima:
},
),
"vae": (
_choices_with_auto(vae_choices(folder_paths)),
automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(QWEN_IMAGE_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
{
"default": AUTO_CHOICE,
"advanced": True,
@@ -142,12 +155,6 @@ class SimpleLoadAnima:
)
def _choices_with_auto(choices: list[str]) -> list[str]:
"""Return choices with the automatic selection first and deduplicated."""
return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)]
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily."""
+6 -4
View File
@@ -143,7 +143,8 @@ SCHEDULER = (
)
POSITIVE_CONDITIONING = "Positive conditioning that guides what the sampler should add."
NEGATIVE_CONDITIONING = (
"Negative conditioning that guides what the sampler should avoid."
"Optional conditioning that guides what the sampler should avoid. Leave "
"disconnected for positive-only sampling without CFG."
)
LATENT_IMAGE = "Latent input whose samples will be denoised."
DENOISE_STRENGTH = (
@@ -226,8 +227,8 @@ DETAIL_POSITIVE = (
"order."
)
DETAIL_NEGATIVE = (
"Negative conditioning for detailing. A conditioning batch is matched to SEGS "
"order."
"Optional negative conditioning for detailing; leave disconnected for "
"positive-only sampling. A conditioning batch is matched to SEGS order."
)
DETAIL_SCALE_FACTOR = (
"Crop enlargement multiplier. Larger values give the sampler more detail room "
@@ -266,7 +267,8 @@ DETAIL_IMAGE_OUTPUT = "Image with the detailed regions blended back into place."
SCALE_FACTOR_OUTPUT = "Multiplier used to scale a connected target."
REGIONAL_GLOBAL_NEGATIVE = (
"Negative conditioning applied across the full regional pass."
"Optional negative conditioning applied across the full regional pass; leave "
"disconnected for positive-only sampling."
)
REGIONAL_GLOBAL_POSITIVE = (
"Positive conditioning that gives full-image context to the regional pass."
+4
View File
@@ -67,6 +67,7 @@ def get_nodes() -> list[type[object]]:
from .simple_load_checkpoint import SimpleLoadCheckpointV3
from .simple_load_flux import SimpleLoadFluxV3
from .simple_load_flux2 import SimpleLoadFlux2V3
from .simple_load_krea2 import SimpleLoadKrea2V3
from .tag_segs_with_external_llm import TagSEGSWithExternalLLMV3
from .tag_segs_with_wd14 import TagSEGSWithWD14V3
from .tile_and_tag_segs import TileAndTagSEGSV3
@@ -121,6 +122,7 @@ def get_nodes() -> list[type[object]]:
SimpleLoadCheckpointV3,
SimpleLoadFluxV3,
SimpleLoadFlux2V3,
SimpleLoadKrea2V3,
SimpleVAEEncodeV3,
TagSEGSWithExternalLLMV3,
TagSEGSWithWD14V3,
@@ -135,6 +137,7 @@ def get_nodes() -> list[type[object]]:
if not prompt_control_is_available():
return nodes
from .apply_automatic_negpip import ApplyAutomaticNegpipV3
from .attach_regional_global_conditioning import (
AttachRegionalGlobalConditioningV3,
)
@@ -149,6 +152,7 @@ def get_nodes() -> list[type[object]]:
return [
*nodes,
ApplyAutomaticNegpipV3,
AttachRegionalGlobalConditioningV3,
EncodePromptBatchWithPromptControl,
LabelRegionalLoraHooksV3,
@@ -0,0 +1,69 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Internal Comfy v3 node for model-family automatic NegPiP preparation."""
from __future__ import annotations
from importlib import import_module
from typing import TYPE_CHECKING, Any
from ..services.negpip_model_service import NEGPIP_MODEL_SERVICE
if TYPE_CHECKING:
class _ComfyNodeBase:
"""Type-checking base for Comfy v3 nodes."""
pass
else:
_ComfyNodeBase = import_module("comfy_api.latest").io.ComfyNode
_comfy_io: Any = None if TYPE_CHECKING else import_module("comfy_api.latest").io
class ApplyAutomaticNegpipV3(_ComfyNodeBase):
"""Patch supported MODEL/CLIP pairs after a negative prompt-weight trigger."""
@classmethod
def define_schema(cls) -> Any:
"""Declare the internal runtime patch boundary."""
return _comfy_io.Schema(
node_id="SimpleSyrup.ApplyAutomaticNegpip",
display_name="Apply Automatic NegPiP (Internal)",
category="SimpleSyrup/Internal",
description=(
"Internal model-family NegPiP preparation injected by Schedule & "
"Encode Prompts after detecting a negative prompt weight."
),
is_dev_only=True,
inputs=[
_comfy_io.Model.Input(
"model",
tooltip="MODEL inspected and cloned only when NegPiP is supported.",
),
_comfy_io.Clip.Input(
"clip",
tooltip="CLIP cloned with the matching NegPiP encoder behavior.",
),
],
outputs=[
_comfy_io.Model.Output(
"model",
tooltip="MODEL carrying one supported NegPiP attention patch set.",
),
_comfy_io.Clip.Output(
"clip",
tooltip="CLIP carrying matching negative-weight encoding behavior.",
),
],
)
@classmethod
def execute(cls, model: object, clip: object) -> tuple[object, object]:
"""Return the supported patched pair or the original unsupported pair."""
return NEGPIP_MODEL_SERVICE.prepare(model, clip)
@@ -10,7 +10,7 @@ from importlib import import_module
from typing import TYPE_CHECKING, Any, ClassVar
from ..domain.prompt_batch_parser import DEFAULT_PROMPT_BATCH_SEPARATOR
from ..runtime.prompt_control_batch_graph import PromptControlBatchGraphBuilder
from ..services.prompt_control_batch_graph import PromptControlBatchGraphBuilder
if TYPE_CHECKING:
@@ -53,12 +53,14 @@ class KSamplerAttentionCouplingV3(_ComfyNodeBase):
"With conditioning batches and masks, denoises supported Anima "
"and standard SD/SDXL models through one "
"shared trajectory while coupling global and masked regional "
"cross-attention. The input MODEL may carry a global LoRA. Anima "
"regions may also carry ordered, independently scheduled Prompt "
"Control model LoRAs whose overlapping deltas compose in declared "
"order. Runtime scales with active adapters, ranks, and targets. "
"Standard SD/SDXL regional model-side hooks and unsupported Anima "
"adapter targets fail before sampling."
"cross-attention. LoRAs on the input MODEL and Prompt Control model "
"LoRAs on global conditioning entry 0 apply across the image. Regions "
"may also carry ordered, independently scheduled model LoRAs "
"whose overlapping deltas compose in declared order. Runtime scales "
"with active adapters, ranks, and targets. "
"Global LoRA and regional LoRA retain independent schedules; "
"regional model-side hooks are supported on admitted model families. "
"Unsupported adapter targets fail before sampling."
),
search_aliases=[
"attention coupling",
@@ -88,9 +90,9 @@ class KSamplerAttentionCouplingV3(_ComfyNodeBase):
sampler_name: str,
scheduler: str,
positive: object,
negative: object,
latent_image: dict[str, Any],
denoise: float,
negative: object | None = None,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
region_masks: object | None = None,
regional_prompt_weight: float = (
ATTENTION_COUPLING_REGIONAL_PROMPT_WEIGHT_DEFAULT
@@ -99,6 +101,8 @@ class KSamplerAttentionCouplingV3(_ComfyNodeBase):
) -> tuple[dict[str, Any]]:
"""Delegate ordinary or regional sampling to the routing service."""
if latent_image is None:
raise TypeError("KSampler Attention Coupling requires latent_image.")
output = cls.sampling_service_class().sample(
model=model,
seed=seed,
@@ -50,12 +50,14 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
description=(
"Preserves large-image composition through Contextual Diffusion "
"while coupling regional attention in every local and reduced-global "
"Anima or standard SD/SDXL view. Global LoRAs remain on the input "
"model. Anima regional LoRA stacks are prepared once, retain "
"Anima or standard SD/SDXL view. LoRAs on the input MODEL and Prompt "
"Control model LoRAs on global conditioning entry 0 apply in every "
"view. Regional LoRA stacks are prepared once, retain "
"independent schedules and full quality, and skip inactive work. "
"Optional SEGS guide the shared local tile plan. Standard SD/SDXL "
"regional model-side hooks and unsupported Anima targets fail before "
"sampling."
"Global LoRA and regional LoRA stacks remain independently scheduled; "
"regional model-side hooks are supported on admitted model families. "
"Optional SEGS guide the shared local tile plan. Unsupported adapter "
"targets fail before sampling."
),
search_aliases=[
"contextual attention coupling",
@@ -96,11 +98,11 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
sampler_name: str,
scheduler: str,
positive: object,
negative: object,
region_masks: object,
regional_prompt_weight: float,
region_mask_feather: int,
latent_image: dict[str, Any],
negative: object | None = None,
region_masks: object | None = None,
regional_prompt_weight: float = 1.0,
region_mask_feather: int = 0,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
diffusion_mode: str = "multidiffusion",
latent_context_size: int = 96,
@@ -113,6 +115,14 @@ class KSamplerContextualAttentionCouplingV3(_ComfyNodeBase):
) -> tuple[dict[str, Any], object]:
"""Delegate the complete request to the combined application service."""
if latent_image is None:
raise TypeError(
"KSampler Contextual Attention Coupling requires latent_image."
)
if region_masks is None:
raise TypeError(
"KSampler Contextual Attention Coupling requires region_masks."
)
result = cls.sampling_service_class().sample(
model=model,
seed=seed,
@@ -90,8 +90,8 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: dict[str, Any],
negative: Any | None = None,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
diffusion_mode: str = "multidiffusion",
latent_context_size: int = 96,
@@ -107,6 +107,8 @@ class KSamplerContextualDiffusionV3(_ComfyNodeBase):
) -> tuple[dict[str, Any], object]:
"""Delegate Contextual Diffusion sampling to its application service."""
if latent_image is None:
raise TypeError("KSampler Contextual Diffusion requires latent_image.")
result = cls.service_class().sample(
model=model,
seed=seed,
@@ -70,15 +70,19 @@ class KSamplerPromptByRegionV3(_ComfyNodeBase):
sampler_name: str,
scheduler: str,
positive: object,
negative: object,
region_masks: object,
regional_prompt_weight: float,
region_mask_feather: int,
latent_image: dict[str, Any],
denoise: float,
negative: object | None = None,
region_masks: object | None = None,
regional_prompt_weight: float = 0.5,
region_mask_feather: int = 0,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
) -> tuple[dict[str, Any]]:
"""Assemble regional conditioning and sample the full latent."""
if region_masks is None:
raise TypeError("KSampler Prompt by Region requires region_masks.")
if latent_image is None:
raise TypeError("KSampler Prompt by Region requires latent_image.")
assembled_positive, assembled_negative = (
cls.conditioning_service_class().assemble(
positive=positive,
@@ -79,20 +79,24 @@ class KSamplerPromptByTiledRegionV3(_ComfyNodeBase):
sampler_name: str,
scheduler: str,
positive: object,
negative: object,
region_masks: object,
regional_prompt_weight: float,
region_mask_feather: int,
latent_image: dict[str, Any],
denoise: float,
diffusion_mode: str,
latent_tile_width: int,
latent_tile_height: int,
latent_tile_overlap: int,
latent_tile_batch_size: int,
negative: object | None = None,
region_masks: object | None = None,
regional_prompt_weight: float = 0.5,
region_mask_feather: int = 0,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
diffusion_mode: str = "multidiffusion",
latent_tile_width: int = 128,
latent_tile_height: int = 128,
latent_tile_overlap: int = 16,
latent_tile_batch_size: int = 4,
) -> tuple[dict[str, Any]]:
"""Assemble regional conditioning and sample overlapping latent tiles."""
if region_masks is None:
raise TypeError("KSampler Prompt by Tiled Region requires region_masks.")
if latent_image is None:
raise TypeError("KSampler Prompt by Tiled Region requires latent_image.")
assembled_positive, assembled_negative = (
cls.conditioning_service_class().assemble(
positive=positive,
+22 -12
View File
@@ -64,7 +64,11 @@ def ksampler_inputs(
tooltip=tooltips.SCHEDULER,
),
conditioning.Input("positive", tooltip=tooltips.POSITIVE_CONDITIONING),
conditioning.Input("negative", tooltip=tooltips.NEGATIVE_CONDITIONING),
conditioning.Input(
"negative",
optional=True,
tooltip=tooltips.NEGATIVE_CONDITIONING,
),
comfy_io.Latent.Input("latent_image", tooltip=tooltips.LATENT_IMAGE),
comfy_io.Float.Input(
"denoise",
@@ -248,7 +252,8 @@ def attention_coupling_ksampler_inputs(
"model",
tooltip=(
"Supported Anima or standard SD/SDXL model used for one shared "
"denoiser trajectory; apply global model LoRAs before connecting it."
"denoiser trajectory. LoRAs patched on this model and Prompt Control "
"model LoRAs on conditioning entry 0 apply globally."
),
),
*base[1:6],
@@ -258,17 +263,20 @@ def attention_coupling_ksampler_inputs(
tooltip=(
"Global-first positive conditioning: entry 0 is global and later "
"entries pair with masks. Regional Prompt Control WeightHooks may "
"contain ordered full-rank Anima LoRA stacks with independent "
"schedules; standard SD/SDXL rejects regional model-side hooks."
"contain ordered regional LoRA stacks with independent schedules. "
"Model LoRA hooks on entry 0 apply across the image."
),
),
comfy_io.MultiType.Input(
"negative",
[comfy_io.Conditioning, conditioning_batch],
optional=True,
tooltip=(
"Global-first negative conditioning aligned to the same masks; "
"Anima regional LoRA hooks retain their negative-branch ownership "
"and independent schedules."
"Optional global-first negative conditioning aligned to the same "
"masks; leave disconnected for positive-only sampling. When used, "
"its global model hooks must match the positive global entry. "
"Regional LoRA hooks retain their negative-branch ownership and "
"independent schedules."
),
),
comfy_io.Mask.Input(
@@ -278,7 +286,7 @@ def attention_coupling_ksampler_inputs(
"Optional ordered masks paired with conditioning entries 1 onward. "
"Leave disconnected with ordinary conditioning to bypass Attention "
"Coupling. In overlaps, prompt contributions are normalized while "
"Anima regional LoRA deltas add in declared adapter and region order."
"regional LoRA deltas add in declared adapter and region order."
),
),
comfy_io.Float.Input(
@@ -291,7 +299,7 @@ def attention_coupling_ksampler_inputs(
tooltip=(
"Balances regional cross-attention against the global prompt from "
"0 (global only) to 1 (regional only inside solid masks); regional "
"Anima LoRA strength remains controlled by each hook."
"LoRA strength remains controlled by each hook."
),
),
comfy_io.Int.Input(
@@ -301,7 +309,7 @@ def attention_coupling_ksampler_inputs(
max=512,
step=1,
tooltip=(
"Softens Attention Coupling and Anima regional LoRA boundaries by "
"Softens Attention Coupling and regional LoRA boundaries by "
"this many image pixels; 0 preserves authored mask values."
),
),
@@ -325,9 +333,11 @@ def regional_conditioning_inputs(comfy_io: Any) -> list[Any]:
comfy_io.MultiType.Input(
"negative",
[comfy_io.Conditioning, conditioning_batch],
optional=True,
tooltip=(
"Negative conditioning whose first batch entry is global and "
"later entries pair with masks in order."
"Optional negative conditioning whose first batch entry is global "
"and later entries pair with masks in order; leave disconnected "
"for positive-only sampling."
),
),
comfy_io.Mask.Input(
@@ -54,12 +54,15 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
"batches and masks, denoises large Anima and standard SD/SDXL "
"latents in tiles through "
"one shared model trajectory per tile batch while coupling global "
"and masked regional cross-attention. The input MODEL may carry "
"global LoRAs. Anima regions may carry independently scheduled "
"and masked regional cross-attention. LoRAs on the input MODEL and "
"Prompt Control model LoRAs on global conditioning entry 0 apply "
"across every tile. Regions may carry independently scheduled "
"regional LoRA stacks; inactive attention and LoRA work is pruned "
"without changing quality. MultiDiffusion or Mixture of Diffusers "
"fuses restored tile predictions. Standard SD/SDXL regional "
"model-side hooks and unsupported Anima targets fail before sampling."
"fuses restored tile predictions. Global LoRA and regional LoRA "
"stacks retain independent schedules; regional model-side hooks are "
"supported on admitted model families. Unsupported adapter targets "
"fail before sampling."
),
search_aliases=[
"attention coupling tiled",
@@ -94,8 +97,8 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
sampler_name: str,
scheduler: str,
positive: object,
negative: object,
latent_image: dict[str, Any],
negative: object | None = None,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
diffusion_mode: str = "multidiffusion",
latent_tile_width: int = 128,
@@ -110,6 +113,8 @@ class KSamplerTiledAttentionCouplingV3(_ComfyNodeBase):
) -> tuple[dict[str, Any]]:
"""Delegate ordinary or regional tiled sampling to the routing service."""
if latent_image is None:
raise TypeError("KSampler Tiled Attention Coupling requires latent_image.")
output = cls.sampling_service_class().sample(
diffusion_mode=diffusion_mode,
model=model,
@@ -84,8 +84,8 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: dict[str, Any],
negative: Any | None = None,
latent_image: dict[str, Any] | None = None,
denoise: float = 1.0,
diffusion_mode: str = "multidiffusion",
latent_tile_width: int = 128,
@@ -99,6 +99,8 @@ class KSamplerTiledDiffusionV3(_ComfyNodeBase):
) -> tuple[dict[str, Any]]:
"""Delegate tiled diffusion sampling to its application service."""
if latent_image is None:
raise TypeError("KSampler Tiled Diffusion requires latent_image.")
output = cls.service_class().sample(
diffusion_mode=diffusion_mode,
model=model,
+32 -8
View File
@@ -40,6 +40,12 @@ from ..nodes.segs_from_sam_output import SEGSFromSAMOutput
from ..nodes.simple_load_anima import SimpleLoadAnima
from ..nodes.simple_preview_segs import SimplePreviewSEGS
from ..nodes.vitmatte_model_loader import ViTMatteModelLoader
from .legacy_workflow_input_order import (
DETAIL_SEGS_AS_REGIONS_INPUT_ORDER,
DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER,
DETAIL_SEGS_BY_SCALE_FACTOR_TILED_INPUT_ORDER,
KSAMPLER_EXTRAS_INPUT_ORDER,
)
if TYPE_CHECKING:
@@ -60,8 +66,6 @@ _HIDDEN_INPUTS = {
"DYNPROMPT": "dynprompt",
"EXTRA_PNGINFO": "extra_pnginfo",
"UNIQUE_ID": "unique_id",
"AUTH_TOKEN_COMFY_ORG": "auth_token_comfy_org",
"API_KEY_COMFY_ORG": "api_key_comfy_org",
}
@@ -72,6 +76,7 @@ class LegacyNodeV3Adapter(_ComfyNodeBase):
NODE_ID: ClassVar[str]
DISPLAY_NAME: ClassVar[str]
ENABLE_EXPAND: ClassVar[bool] = False
WORKFLOW_INPUT_ORDER: ClassVar[tuple[str, ...] | None] = None
@classmethod
def define_schema(cls) -> Any:
@@ -84,7 +89,10 @@ class LegacyNodeV3Adapter(_ComfyNodeBase):
category=str(getattr(legacy, "CATEGORY", "SimpleSyrup")),
description=str(getattr(legacy, "DESCRIPTION", "")),
search_aliases=list(getattr(legacy, "SEARCH_ALIASES", [])),
inputs=_v3_inputs(legacy.INPUT_TYPES()),
inputs=_v3_inputs(
legacy.INPUT_TYPES(),
workflow_order=cls.WORKFLOW_INPUT_ORDER,
),
outputs=_v3_outputs(legacy),
hidden=_v3_hidden_inputs(legacy.INPUT_TYPES()),
is_input_list=bool(getattr(legacy, "INPUT_IS_LIST", False)),
@@ -147,6 +155,7 @@ class KSamplerExtrasV3(LegacyNodeV3Adapter):
LEGACY_NODE_CLASS = KSamplerExtras
NODE_ID = "SimpleSyrup.KSamplerExtras"
DISPLAY_NAME = "KSampler (Extras)"
WORKFLOW_INPUT_ORDER = KSAMPLER_EXTRAS_INPUT_ORDER
class LayerStyleSAMModelsAdapterV3(LegacyNodeV3Adapter):
@@ -221,6 +230,7 @@ class DetailSEGSAsRegionsV3(LegacyNodeV3Adapter):
LEGACY_NODE_CLASS = DetailSEGSAsRegions
NODE_ID = "SimpleSyrup.DetailSEGSAsRegions"
DISPLAY_NAME = "Detail SEGS as Regions"
WORKFLOW_INPUT_ORDER = DETAIL_SEGS_AS_REGIONS_INPUT_ORDER
class DetailSEGSByScaleFactorV3(LegacyNodeV3Adapter):
@@ -229,6 +239,7 @@ class DetailSEGSByScaleFactorV3(LegacyNodeV3Adapter):
LEGACY_NODE_CLASS = DetailSEGSByScaleFactor
NODE_ID = "SimpleSyrup.DetailSEGSByScaleFactor"
DISPLAY_NAME = "Detail SEGS by Scale Factor"
WORKFLOW_INPUT_ORDER = DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER
class DetailSEGSByScaleFactorTiledDiffusionV3(LegacyNodeV3Adapter):
@@ -237,6 +248,7 @@ class DetailSEGSByScaleFactorTiledDiffusionV3(LegacyNodeV3Adapter):
LEGACY_NODE_CLASS = DetailSEGSByScaleFactorTiledDiffusion
NODE_ID = "SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion"
DISPLAY_NAME = "Detail SEGS by Scale Factor w/ Tiled Diffusion"
WORKFLOW_INPUT_ORDER = DETAIL_SEGS_BY_SCALE_FACTOR_TILED_INPUT_ORDER
class SAMModelLoaderV3(LegacyNodeV3Adapter):
@@ -311,15 +323,27 @@ class ViTMatteModelLoaderV3(LegacyNodeV3Adapter):
DISPLAY_NAME = "ViTMatte Model Loader"
def _v3_inputs(input_types: Mapping[str, Mapping[str, object]]) -> list[Any]:
"""Return v3 input declarations from legacy required and optional inputs."""
def _v3_inputs(
input_types: Mapping[str, Mapping[str, object]],
*,
workflow_order: tuple[str, ...] | None = None,
) -> list[Any]:
"""Return v3 inputs while preserving any explicit persisted socket order."""
inputs: list[Any] = []
declarations: dict[str, tuple[object, bool]] = {}
for section_name, optional in (("required", False), ("optional", True)):
section = input_types.get(section_name, {})
for name, declaration in section.items():
inputs.append(_v3_input(name, declaration, optional=optional))
return inputs
if name in declarations:
raise ValueError(f"legacy input {name} is declared more than once.")
declarations[name] = (declaration, optional)
order = tuple(declarations) if workflow_order is None else workflow_order
if len(order) != len(set(order)) or set(order) != set(declarations):
raise ValueError("legacy workflow input order must name every input once.")
return [
_v3_input(name, declarations[name][0], optional=declarations[name][1])
for name in order
]
def _v3_input(name: str, declaration: object, *, optional: bool) -> Any:
@@ -0,0 +1,74 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Retain persisted socket order for legacy-backed Comfy v3 nodes."""
KSAMPLER_EXTRAS_INPUT_ORDER = (
"model",
"seed",
"steps",
"cfg",
"sampler_name",
"scheduler",
"positive",
"negative",
"latent_image",
"denoise",
)
DETAIL_SEGS_AS_REGIONS_INPUT_ORDER = (
"image",
"model",
"vae",
"negative",
"positive",
"segs",
"region_positive",
"global_prompt_weight",
"scale_factor",
"upscale_method",
"seed",
"steps",
"cfg",
"sampler_name",
"scheduler",
"denoise",
"feather",
"noise_mask",
"noise_mask_feather",
"tiled_encode",
"tiled_decode",
)
DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER = (
"image",
"segs",
"model",
"vae",
"positive",
"negative",
"scale_factor",
"upscale_method",
"clamp_size",
"seed",
"steps",
"cfg",
"sampler_name",
"scheduler",
"denoise",
"feather",
"noise_mask",
"noise_mask_feather",
"tiled_encode",
"tiled_decode",
)
DETAIL_SEGS_BY_SCALE_FACTOR_TILED_INPUT_ORDER = (
*DETAIL_SEGS_BY_SCALE_FACTOR_INPUT_ORDER,
"diffusion_mode",
"latent_tile_width",
"latent_tile_height",
"latent_tile_overlap",
"latent_tile_batch_size",
)
+1 -1
View File
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
import torch
from ..domain.segs import SORT_ORDER_OPTIONS, NativeSegs
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
from ..services.mask_to_segs_service import MaskToSEGSService
from ..services.segs_output_service import (
CombinedSegsResult,
@@ -9,7 +9,7 @@ from __future__ import annotations
from importlib import import_module
from typing import TYPE_CHECKING, Any, ClassVar
from ..runtime.prompt_control_schedule_encode_graph import (
from ..services.prompt_control_schedule_encode_graph import (
PromptControlScheduleEncodeGraphBuilder,
)
@@ -94,10 +94,12 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase):
"negative_prompt",
multiline=False,
default="",
optional=True,
tooltip=(
"Negative Prompt-Control text; [SEP] or [SEP|name] creates "
"ordered conditioning entries, and global text fills "
"missing negative regions."
"Optional negative Prompt-Control text; [SEP] or [SEP|name] "
"creates ordered conditioning entries, and global text fills "
"missing negative regions. Leave disconnected to encode an "
"empty negative prompt."
),
),
],
@@ -126,7 +128,7 @@ class ScheduleAndEncodePromptsWithPromptControl(_ComfyNodeBase):
model: Any,
clip: Any,
positive_prompt: str,
negative_prompt: str,
negative_prompt: str = "",
encode_style: str = "",
) -> Any:
"""Build lazy Prompt-Control graph expansion for prompts."""
+21 -12
View File
@@ -11,7 +11,9 @@ from types import ModuleType
from typing import TYPE_CHECKING, Any, ClassVar
from ..nodes import tooltips
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.flux_artifacts import FLUX_CLIP_L, FLUX_T5_XXL, FLUX_VAE
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES
from ..runtime.vae_loader import vae_choices
@@ -44,9 +46,7 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
"""Declare the separate FLUX.1 loader schema."""
folder_paths = _folder_paths()
text_encoder_choices = _choices_with_auto(
list(folder_paths.get_filename_list("text_encoders"))
)
installed_text_encoders = list(folder_paths.get_filename_list("text_encoders"))
return _comfy_io.Schema(
node_id="SimpleSyrup.SimpleLoadFlux",
display_name="Simple Load FLUX",
@@ -77,7 +77,12 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"clip_l",
options=text_encoder_choices,
options=automatic_component_choices(
installed=installed_text_encoders,
artifacts=(FLUX_CLIP_L,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -87,7 +92,12 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"t5_xxl",
options=text_encoder_choices,
options=automatic_component_choices(
installed=installed_text_encoders,
artifacts=(FLUX_T5_XXL,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -107,7 +117,12 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"vae",
options=_choices_with_auto(vae_choices(folder_paths)),
options=automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(FLUX_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -146,12 +161,6 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
)
def _choices_with_auto(choices: list[str]) -> list[str]:
"""Return deduplicated choices with automatic selection first."""
return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)]
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily for schema declaration."""
+13 -9
View File
@@ -11,7 +11,9 @@ from types import ModuleType
from typing import TYPE_CHECKING, Any, ClassVar
from ..nodes import tooltips
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.flux_artifacts import FLUX2_TEXT_ENCODERS, FLUX2_VAE
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES
from ..runtime.vae_loader import vae_choices
@@ -75,8 +77,11 @@ class SimpleLoadFlux2V3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"text_encoder",
options=_choices_with_auto(
list(folder_paths.get_filename_list("text_encoders"))
options=automatic_component_choices(
installed=list(folder_paths.get_filename_list("text_encoders")),
artifacts=tuple(FLUX2_TEXT_ENCODERS.values()),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
@@ -98,7 +103,12 @@ class SimpleLoadFlux2V3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"vae",
options=_choices_with_auto(vae_choices(folder_paths)),
options=automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(FLUX2_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -135,12 +145,6 @@ class SimpleLoadFlux2V3(_ComfyNodeBase):
)
def _choices_with_auto(choices: list[str]) -> list[str]:
"""Return deduplicated choices with automatic selection first."""
return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)]
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily for schema declaration."""
+166
View File
@@ -0,0 +1,166 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Expose cohesive Krea 2 component loading through Comfy's v3 API."""
from __future__ import annotations
import importlib
from types import ModuleType
from typing import TYPE_CHECKING, Any, ClassVar
from ..nodes import tooltips
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.krea2_artifacts import (
KREA2_AUTO_TEXT_ENCODER,
KREA2_QWEN3_VL_4B_BF16,
KREA2_QWEN3_VL_4B_FP8,
)
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.qwen_artifacts import QWEN_IMAGE_VAE
from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES
from ..runtime.vae_loader import vae_choices
from ..services.krea2_loader_service import AUTO_CHOICE, Krea2LoaderService
if TYPE_CHECKING:
class _ComfyNodeBase:
"""Type-checking base for Comfy v3 nodes."""
RETURN_TYPES: ClassVar[list[str]]
RETURN_NAMES: ClassVar[list[str]]
else:
_ComfyNodeBase = importlib.import_module("comfy_api.latest").io.ComfyNode
_comfy_io: Any = (
None if TYPE_CHECKING else importlib.import_module("comfy_api.latest").io
)
class SimpleLoadKrea2V3(_ComfyNodeBase):
"""Load a Krea 2 diffusion model with its Qwen encoder and image VAE."""
_service = Krea2LoaderService()
@classmethod
def define_schema(cls) -> Any:
"""Declare the Krea 2 loader schema and downloadable component choices."""
folder_paths = _folder_paths()
return _comfy_io.Schema(
node_id="SimpleSyrup.SimpleLoadKrea2",
display_name="Simple Load Krea 2",
category="SimpleSyrup/Loaders",
description=(
"Loads Krea 2 with its Qwen3-VL 4B encoder and Qwen Image VAE; "
"automatic components are downloaded from checksum-pinned "
"Hugging Face files."
),
search_aliases=["krea", "krea 2", "k2", "load krea"],
inputs=[
_comfy_io.Combo.Input(
"diffusion_model",
options=list(folder_paths.get_filename_list("diffusion_models")),
tooltip=(
"Krea 2 Raw or Turbo diffusion model to load. This node "
"validates the architecture and never downloads this file."
),
),
_comfy_io.Combo.Input(
"diffusion_weight_dtype",
options=list(DIFFUSION_WEIGHT_DTYPES),
default="default",
advanced=True,
tooltip=(
"Load-time diffusion precision; default preserves the "
"selected file's stored BF16, FP8, INT8, MXFP8, or NVFP4 "
"format."
),
),
_comfy_io.Combo.Input(
"text_encoder",
options=automatic_component_choices(
installed=list(folder_paths.get_filename_list("text_encoders")),
artifacts=(
KREA2_QWEN3_VL_4B_FP8,
KREA2_QWEN3_VL_4B_BF16,
),
leading_choices=(
KREA2_AUTO_TEXT_ENCODER,
KREA2_QWEN3_VL_4B_FP8.filename,
KREA2_QWEN3_VL_4B_BF16.filename,
),
folder_paths_module=folder_paths,
),
default=KREA2_AUTO_TEXT_ENCODER,
advanced=True,
tooltip=(
"Qwen3-VL 4B encoder loaded with Krea 2's required 12-layer "
"conditioning. Auto uses FP8; selecting official FP8 or BF16 "
"downloads that checksum-pinned file when missing."
),
),
_comfy_io.Combo.Input(
"text_encoder_device",
options=list(TEXT_ENCODER_DEVICES),
default="default",
advanced=True,
tooltip=(
"Device for Qwen3-VL; CPU saves GPU memory but makes prompt "
"encoding slower."
),
),
_comfy_io.Combo.Input(
"vae",
options=automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(QWEN_IMAGE_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
"VAE used to decode Krea 2 latents. Auto finds or downloads "
"the checksum-pinned Qwen Image VAE with visible progress."
),
),
],
outputs=[
_comfy_io.Model.Output("model", tooltip=tooltips.MODEL_OUTPUT),
_comfy_io.Clip.Output("clip", tooltip=tooltips.CLIP_OUTPUT),
_comfy_io.Vae.Output("vae", tooltip=tooltips.VAE_OUTPUT),
],
)
@classmethod
def execute(
cls,
diffusion_model: str,
diffusion_weight_dtype: str,
text_encoder: str,
text_encoder_device: str,
vae: str,
) -> tuple[object, object, object]:
"""Load and return validated Krea 2 MODEL, CLIP, and VAE objects."""
return cls._service.load_models(
diffusion_model=diffusion_model,
diffusion_weight_dtype=diffusion_weight_dtype,
text_encoder=text_encoder,
text_encoder_device=text_encoder_device,
vae=vae,
progress=ComfyProgressReporter(),
)
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily for schema declaration."""
module: Any = importlib.import_module("folder_paths")
if not isinstance(module, ModuleType):
raise TypeError("folder_paths import did not return a module.")
return module
+3 -15
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
from .auto_model_artifact import AutoModelArtifact
from .qwen_artifacts import QWEN_IMAGE_VAE
ANIMA_QWEN_TEXT_ENCODER = AutoModelArtifact(
cache_id="anima_qwen_text_encoder",
@@ -20,20 +21,7 @@ ANIMA_QWEN_TEXT_ENCODER = AutoModelArtifact(
source_repo="circlestone-labs/Anima",
description="Anima Qwen3 0.6B text encoder",
sha256="cd2a512003e2f9f3cd3c32a9c3573f820bb28c940f73c57b1ddaa983d9223eba",
file_size_bytes=1_192_135_096,
)
ANIMA_QWEN_VAE = AutoModelArtifact(
cache_id="anima_qwen_vae",
filename="qwen_image_vae.safetensors",
folder_name="vae",
canonical_subfolder="qwen",
source_url=(
"https://huggingface.co/circlestone-labs/Anima/resolve/main/"
"split_files/vae/qwen_image_vae.safetensors"
),
source_repo="circlestone-labs/Anima",
description="Anima Qwen Image VAE",
sha256="a70580f0213e67967ee9c95f05bb400e8fb08307e017a924bf3441223e023d1f",
)
ANIMA_AUTO_ARTIFACTS = (ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE)
ANIMA_AUTO_ARTIFACTS = (ANIMA_QWEN_TEXT_ENCODER, QWEN_IMAGE_VAE)
+53 -44
View File
@@ -8,17 +8,19 @@ from __future__ import annotations
from dataclasses import dataclass
import comfy.model_patcher
from comfy.patcher_extension import CallbacksMP
from ..model_attention_patch_mutations import ModelAttn2PatchesMutation
from ..model_patcher_mutations import ModelKeyedCallbackMutation
from ..patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
from ..ppm_negpip_interop import PpmNegpipInterop
from ..regional_lora.standard_unet_native_admission import (
StandardUnetNativeLoraAdmission,
from ..regional_lora.operation_assembly import REGIONAL_OPERATION_ASSEMBLER
from ..regional_lora.standard_unet_operation_preparation import (
StandardUnetOperationAdmission,
)
from ..regional_lora.standard_unet_variant_runtime import (
StandardUnetVariantRuntimeMutation,
)
from ..regional_lora.standard_unet_variant_template import (
STANDARD_UNET_VARIANT_TEMPLATE_CACHE,
from ..regional_lora.standard_unet_operation_session import (
StandardUnetRegionalOperationSession,
)
from .unet_attention_context_wrapper import unet_attention_context_wrapper_mutation
from .unet_attention_phase_session import StandardUnetAttentionPhaseSession
@@ -43,15 +45,15 @@ class StandardUnetAttentionBackend:
*,
model: object,
state: StandardUnetAttentionState,
admission: StandardUnetNativeLoraAdmission,
admission: StandardUnetOperationAdmission,
negpip: PpmNegpipInterop | None = None,
) -> StandardUnetAttentionModel:
"""Return a direct MODEL child containing only the paired UNet patches."""
if not isinstance(state, StandardUnetAttentionState):
raise TypeError("Standard UNet backend requires attention state.")
if not isinstance(admission, StandardUnetNativeLoraAdmission):
raise TypeError("Standard UNet backend requires native admission.")
if not isinstance(admission, StandardUnetOperationAdmission):
raise TypeError("Standard UNet backend requires operation admission.")
if admission.adaptation.plan != state.plan.lora_plan:
raise ValueError(
"Standard UNet admission and processed conditioning must share "
@@ -60,48 +62,55 @@ class StandardUnetAttentionBackend:
if negpip is not None and not isinstance(negpip, PpmNegpipInterop):
raise TypeError("Standard UNet backend NegPiP state has an invalid type.")
attention_phase = StandardUnetAttentionPhaseSession()
template = (
STANDARD_UNET_VARIANT_TEMPLATE_CACHE.resolve(model, admission)
if admission.adaptation.plan.adapters
else None
)
variant_mutations = (
(
StandardUnetVariantRuntimeMutation(
state,
admission,
attention_phase,
template,
negpip,
operation_session: StandardUnetRegionalOperationSession | None = None
operation_mutations: tuple[ModelMutation, ...] = ()
if admission.adaptation.plan.adapters:
if (
not isinstance(model, comfy.model_patcher.ModelPatcher)
or admission.binding is None
or admission.cache is None
):
raise TypeError(
"Standard UNet regional operations require complete MODEL "
"admission."
)
assembly = REGIONAL_OPERATION_ASSEMBLER.assemble(
admission.binding,
model=model,
cache=admission.cache,
)
operation_session = StandardUnetRegionalOperationSession(
admission.adaptation.plan,
state.plan.mask_bank,
admission.module_roles,
assembly.call_scope,
)
operation_mutations = (
assembly.cache_lifecycle.mutation(),
ModelKeyedCallbackMutation(
CallbacksMP.ON_DETACH,
"simple_syrup.standard_unet_regional_operation_schedule",
operation_session.clear,
),
)
if template is not None
else ()
patches = UnetAttn2PatchPair(
StandardUnetAttn2ExecutionResolver(state),
operation_scope=operation_session,
)
derivation_source = (
template.bind_request(model) if template is not None else model
)
attention_mutations: tuple[ModelMutation, ...] = ()
if template is None:
patches = UnetAttn2PatchPair(
StandardUnetAttn2ExecutionResolver(state),
)
attention_mutations = (
derived = PATCHER_LIFECYCLE.derive_model(
model,
(
unet_attention_context_wrapper_mutation(
state,
attention_phase,
operation_session,
),
ModelAttn2PatchesMutation(
patches.input_patch,
patches.output_patch,
(() if negpip is None else (negpip.attention_patch,)),
),
)
derived = PATCHER_LIFECYCLE.derive_model(
derivation_source,
(
unet_attention_context_wrapper_mutation(
state,
attention_phase,
),
*attention_mutations,
*variant_mutations,
*operation_mutations,
),
operation="standard UNet Attention Coupling",
)
@@ -6,12 +6,17 @@
from __future__ import annotations
from contextlib import ExitStack
import torch
from ..diffusion_wrapper_executor import DiffusionWrapperExecutor
from ..diffusion_wrapper_invocation import DIFFUSION_WRAPPER_INVOCATION_VALIDATOR
from ..model_patcher_mutations import ModelDiffusionWrapperMutation
from ..regional_attention_model_call import RegionalAttentionModelCallResolver
from ..regional_lora.standard_unet_operation_session import (
StandardUnetRegionalOperationSession,
)
from .standard_unet_model_output_validation import (
STANDARD_UNET_MODEL_OUTPUT_VALIDATOR,
StandardUnetModelOutputValidator,
@@ -32,6 +37,7 @@ class StandardUnetAttentionContextDiffusionWrapper:
self,
state: StandardUnetAttentionState,
attention_phase: StandardUnetAttentionPhaseSession,
operation_session: StandardUnetRegionalOperationSession | None = None,
*,
model_call_resolver: RegionalAttentionModelCallResolver = (
STANDARD_UNET_MODEL_CALL_RESOLVER
@@ -46,12 +52,18 @@ class StandardUnetAttentionContextDiffusionWrapper:
raise TypeError("Standard UNet context wrapper requires attention state.")
if not isinstance(attention_phase, StandardUnetAttentionPhaseSession):
raise TypeError("Standard UNet context wrapper requires phase state.")
if operation_session is not None and not isinstance(
operation_session,
StandardUnetRegionalOperationSession,
):
raise TypeError("Standard UNet operation session has an invalid type.")
if not isinstance(model_call_resolver, RegionalAttentionModelCallResolver):
raise TypeError(
"Standard UNet context wrapper requires a model-call resolver."
)
self._state = state
self._attention_phase = attention_phase
self._operation_session = operation_session
self._model_call_resolver = model_call_resolver
if not isinstance(output_validator, StandardUnetModelOutputValidator):
raise TypeError("Standard UNet output validator has an invalid type.")
@@ -90,11 +102,14 @@ class StandardUnetAttentionContextDiffusionWrapper:
transformer_options=args[5],
)
forwarded_args = (*args[:2], contexts.base_context, *args[3:])
with (
self._attention_phase.activate(args[5]),
self._state.execution_context.activate(contexts),
self._state.resolution_cache.activate(),
):
with ExitStack() as scopes:
scopes.enter_context(self._attention_phase.activate(args[5]))
scopes.enter_context(self._state.execution_context.activate(contexts))
scopes.enter_context(self._state.resolution_cache.activate())
if self._operation_session is not None:
scopes.enter_context(
self._operation_session.activate(contexts, args[5])
)
output = executor(*forwarded_args, **kwargs)
return self._output_validator.validate(output, model_input=args[0])
@@ -102,6 +117,7 @@ class StandardUnetAttentionContextDiffusionWrapper:
def unet_attention_context_wrapper_mutation(
state: StandardUnetAttentionState,
attention_phase: StandardUnetAttentionPhaseSession,
operation_session: StandardUnetRegionalOperationSession | None = None,
) -> ModelDiffusionWrapperMutation:
"""Return the clone-local standard-UNet context wrapper mutation."""
@@ -110,5 +126,6 @@ def unet_attention_context_wrapper_mutation(
StandardUnetAttentionContextDiffusionWrapper(
state,
attention_phase,
operation_session,
),
)
@@ -21,3 +21,4 @@ class AutoModelArtifact:
source_repo: str
description: str
sha256: str
file_size_bytes: int
@@ -0,0 +1,72 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Build component choices without duplicating automatic local artifacts."""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from types import ModuleType
from typing import Any
from .auto_model_artifact import AutoModelArtifact
def automatic_component_choices(
installed: Sequence[str],
artifacts: Sequence[AutoModelArtifact],
leading_choices: Sequence[str],
folder_paths_module: ModuleType,
) -> list[str]:
"""Return leading choices plus local files not represented automatically."""
if not artifacts:
raise ValueError("Automatic component choices require at least one artifact.")
folder_names = {artifact.folder_name for artifact in artifacts}
if len(folder_names) != 1:
raise ValueError("Automatic component artifacts must share one model category.")
folder_name = next(iter(folder_names))
automatic_names = {artifact.filename for artifact in artifacts}
automatic_sizes = {artifact.file_size_bytes for artifact in artifacts}
choices = list(dict.fromkeys(leading_choices))
seen = set(choices)
for choice in installed:
if choice in seen or choice in automatic_names:
continue
if _installed_file_has_known_size(
folder_paths_module,
folder_name,
choice,
automatic_sizes,
):
continue
choices.append(choice)
seen.add(choice)
return choices
def _installed_file_has_known_size(
folder_paths_module: ModuleType,
folder_name: str,
choice: str,
automatic_sizes: set[int],
) -> bool:
"""Identify a likely automatic artifact without hashing during schema creation."""
get_full_path: Any = getattr(folder_paths_module, "get_full_path", None)
if not callable(get_full_path):
return False
path_value: Any = get_full_path(folder_name, choice)
if path_value is None:
return False
try:
path = Path(str(path_value))
return path.is_file() and path.stat().st_size in automatic_sizes
except OSError:
return False
__all__ = ["automatic_component_choices"]
+74 -6
View File
@@ -150,8 +150,6 @@ class AutoModelResolver:
return False
if entry.sha256 != artifact.sha256:
return False
if entry.path.name != artifact.filename:
return False
if not entry.path.is_file():
return False
try:
@@ -163,6 +161,8 @@ class AutoModelResolver:
except ValueError:
return False
stat = entry.path.stat()
if stat.st_size != artifact.file_size_bytes:
return False
if entry.file_size is not None and entry.modified_time_ns is not None:
return (
entry.file_size == stat.st_size
@@ -175,19 +175,33 @@ def find_model_artifact(
artifact: AutoModelArtifact,
folder_paths_module: ModuleType | None = None,
) -> Path | None:
"""Return the first same-named local file matching the catalog checksum."""
"""Find an artifact by the cheapest reliable checks within its model category."""
_validate_basename(artifact.filename)
for root in get_model_folder_paths(artifact.folder_name, folder_paths_module):
roots = get_model_folder_paths(artifact.folder_name, folder_paths_module)
canonical = canonical_auto_destination(artifact, folder_paths_module)
if _candidate_matches_artifact(canonical, artifact):
return canonical
checked: set[Path] = {canonical.resolve()}
for root in roots:
if not root.is_dir():
continue
matches = sorted(
path for path in root.rglob(artifact.filename) if path.is_file()
)
for match in matches:
if match.name != artifact.filename or not _path_is_under(match, root):
if not _candidate_has_expected_size(match, artifact):
continue
if sha256_file(match).lower() == artifact.sha256.lower():
resolved_match = match.resolve()
if (
resolved_match in checked
or match.name != artifact.filename
or not _path_is_under(match, root)
):
continue
checked.add(resolved_match)
if _candidate_checksum_matches(match, artifact):
return match
LOGGER.warning(
"same-named auto model artifact has a different checksum",
@@ -197,9 +211,63 @@ def find_model_artifact(
"artifact_filename": artifact.filename,
},
)
for root in roots:
if not root.is_dir():
continue
for candidate in root.rglob("*"):
if not _candidate_has_expected_size(candidate, artifact):
continue
if not _path_is_under(candidate, root):
continue
resolved_candidate = candidate.resolve()
if resolved_candidate in checked:
continue
checked.add(resolved_candidate)
if not _candidate_checksum_matches(candidate, artifact):
continue
LOGGER.info(
"auto model artifact found under alternate filename",
extra={
"cache_id": artifact.cache_id,
"path": str(candidate),
"artifact_filename": artifact.filename,
},
)
return candidate
return None
def _candidate_matches_artifact(
candidate: Path,
artifact: AutoModelArtifact,
) -> bool:
"""Verify a candidate only when its inexpensive file checks match first."""
return _candidate_has_expected_size(
candidate,
artifact,
) and _candidate_checksum_matches(candidate, artifact)
def _candidate_has_expected_size(
candidate: Path,
artifact: AutoModelArtifact,
) -> bool:
"""Return whether a regular file has the artifact's exact byte size."""
return candidate.is_file() and candidate.stat().st_size == artifact.file_size_bytes
def _candidate_checksum_matches(
candidate: Path,
artifact: AutoModelArtifact,
) -> bool:
"""Return whether an already size-matched candidate has the trusted digest."""
return sha256_file(candidate).lower() == artifact.sha256.lower()
def find_model_by_basename(
folder_name: str,
basename: str,
@@ -6,6 +6,7 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast
@@ -50,6 +51,79 @@ class ClipHookScheduleMutation:
register_hooks(self.hooks, self.target)
@dataclass(frozen=True)
class ClipCallableObjectPatchMutation:
"""Patch one callable text-encoder object on a derived CLIP patcher."""
path: str
replacement: Callable[..., object]
def apply(self, clip: object) -> None:
"""Validate the path and collision state before installing the callback."""
if (
not isinstance(self.path, str)
or not self.path
or any(not segment for segment in self.path.split("."))
):
raise ValueError("CLIP callable patch path must be a dotted path.")
if not callable(self.replacement):
raise TypeError("CLIP callable object replacement must be callable.")
patcher = _required_attribute(clip, "patcher", value_name="CLIP")
getter = getattr(patcher, "get_model_object", None)
adder = getattr(patcher, "add_object_patch", None)
object_patches = getattr(patcher, "object_patches", None)
if (
not callable(getter)
or not callable(adder)
or not isinstance(object_patches, dict)
):
raise TypeError("CLIP patcher does not expose callable object patches.")
if self.path in object_patches:
raise ValueError(f"CLIP object path '{self.path}' already has a patch.")
if not callable(getter(self.path)):
raise TypeError(f"CLIP object path '{self.path}' must be callable.")
adder(self.path, self.replacement)
@dataclass(frozen=True)
class ClipTokenizerMutation:
"""Replace the tokenizer on a derived CLIP after exact source validation."""
expected_source: object
replacement: object
def apply(self, clip: object) -> None:
"""Install one tokenizer proxy only on the expected cloned source value."""
if getattr(clip, "tokenizer", None) is not self.expected_source:
raise ValueError("Derived CLIP tokenizer does not match its source.")
cast(Any, clip).tokenizer = self.replacement
@dataclass(frozen=True)
class ClipBooleanOptionMutation:
"""Publish one collision-safe boolean option on a derived CLIP patcher."""
key: str
value: bool
def apply(self, clip: object) -> None:
"""Set an approved ownership marker after validating the option mapping."""
if self.key not in {"ppm_negpip", "simple_syrup_negpip"}:
raise ValueError("Unsupported CLIP boolean option marker.")
if not isinstance(self.value, bool):
raise TypeError("CLIP option marker value must be boolean.")
patcher = _required_attribute(clip, "patcher", value_name="CLIP")
options = getattr(patcher, "model_options", None)
if not isinstance(options, dict):
raise TypeError("CLIP patcher model_options must be a dictionary.")
if self.key in options:
raise ValueError(f"CLIP option '{self.key}' is already present.")
options[self.key] = self.value
def _required_attribute(value: object, name: str, *, value_name: str) -> object:
"""Return a required dynamic ComfyUI boundary attribute."""
+25
View File
@@ -0,0 +1,25 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Validate ComfyUI text-encoder type availability before model downloads."""
from __future__ import annotations
import importlib
from typing import Any
class ComfyClipTypeSupport:
"""Validate named ComfyUI CLIP types at the runtime boundary."""
def require(self, clip_type_name: str) -> None:
"""Raise an actionable error when ComfyUI lacks a required CLIP type."""
comfy_sd: Any = importlib.import_module("comfy.sd")
if hasattr(comfy_sd.CLIPType, clip_type_name):
return
raise RuntimeError(
f"This loader requires ComfyUI CLIP type '{clip_type_name}'. "
"Update ComfyUI before using this node."
)
@@ -13,6 +13,7 @@ from uuid import UUID
import torch
from comfy import sampler_helpers, samplers
from ..domain.attention_coupling_preparation import AttentionCouplingPreparation
from ..domain.conditioning_schedule import ConditioningScheduleRange
from ..domain.processed_regional_attention import (
ProcessedRegionalAttentionBranch,
@@ -23,9 +24,6 @@ from ..domain.processed_regional_attention import (
from ..domain.raw_regional_attention import (
RawRegionalAttentionBranch,
)
from ..services.attention_coupling_preparation_service import (
AttentionCouplingPreparation,
)
from .attention_coupling.context_validation import RegionalContextValidator
from .ppm_negpip_interop import PpmNegpipInterop
@@ -23,6 +23,7 @@ from ..domain.regional_features import (
from ..shared.logging import get_logger
from . import sampling_samplers, sampling_schedulers
from .contextual_model_wrapper import ContextualDiffusionModelWrapper
from .guided_sampling import sample_with_optional_negative
from .model_patcher_mutations import ModelUnetWrapperMutation
from .patcher_lifecycle import PATCHER_LIFECYCLE
from .sampling_model_types import ModelFunctionWrapper
@@ -117,15 +118,16 @@ def sample_contextual_diffusion(
batch_inds = latent_image.get("batch_index")
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
callback = _latent_preview().prepare_callback(sampling_model, steps)
samples = comfy_sample.sample_custom(
sampling_model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_samples,
samples = sample_with_optional_negative(
comfy_sample=comfy_sample,
model=sampling_model,
noise=noise,
cfg=cfg,
sampler=sampler,
sigmas=sigmas,
positive=positive,
negative=negative,
latent_image=latent_samples,
noise_mask=latent_image.get("noise_mask"),
callback=callback,
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
@@ -49,6 +49,7 @@ class ContextualDiffusionModelWrapper:
self._tile_predictions = TilePredictionAccumulator(
plan.tile_plan,
diffusion_mode=diffusion_mode,
project_canvas_reference_latents=True,
)
@property
@@ -110,6 +111,7 @@ class ContextualDiffusionModelWrapper:
global_args = make_spatial_view_model_args(
args=args,
layout=global_layout,
project_canvas_reference_latents=True,
)
global_prediction = self._call_original(apply_model, global_args)
global_view = self._plan.global_view
@@ -0,0 +1,81 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Convert and align image assets used by detail sampling previews."""
from __future__ import annotations
import numpy as np
import torch
from PIL import Image
from ..domain.segs import CropRegion
CropBox = tuple[int, int, int, int]
def image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image:
"""Convert a single-image BHWC tensor to an RGB PIL image."""
if image.ndim != 4:
raise ValueError("detail preview image must be a BHWC tensor.")
if int(image.shape[0]) != 1:
raise ValueError("detail preview image must contain exactly one image.")
if int(image.shape[-1]) < 1:
raise ValueError("detail preview image must contain at least one channel.")
array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy()
if array.shape[-1] == 1:
array = np.repeat(array, 3, axis=-1)
elif array.shape[-1] >= 3:
array = array[..., :3]
else:
array = np.repeat(array[..., :1], 3, axis=-1)
return Image.fromarray((array * 255.0).round().astype(np.uint8))
def normalize_mask_tensor(mask: torch.Tensor) -> torch.Tensor:
"""Normalize an HW or single-item BHW mask tensor to HW float."""
working = mask.detach().float()
if working.ndim == 3 and int(working.shape[0]) == 1:
working = working[0]
if working.ndim != 2:
raise ValueError("detail preview work mask must be an HW tensor.")
return working
def detail_alpha_mask(
mask: torch.Tensor,
*,
source_size: tuple[int, int],
preview_size: tuple[int, int],
sampled_box: CropBox,
target_size: tuple[int, int],
) -> Image.Image:
"""Return an alpha mask aligned to the sampled preview paste box."""
working = normalize_mask_tensor(mask).detach().cpu().clamp(0.0, 1.0)
mask_image = Image.fromarray((working.numpy() * 255.0).round().astype(np.uint8))
if mask_image.size == source_size:
preview_mask = mask_image.resize(preview_size, Image.Resampling.BILINEAR)
return preview_mask.crop(sampled_box).resize(
target_size,
Image.Resampling.BILINEAR,
)
return mask_image.resize(target_size, Image.Resampling.BILINEAR)
def validate_crop_region(
crop_region: CropRegion,
source_width: int,
source_height: int,
) -> None:
"""Reject crop regions that cannot be mapped into the source image."""
if crop_region.left < 0 or crop_region.top < 0:
raise ValueError("crop_region left and top must be non-negative.")
if crop_region.right <= crop_region.left or crop_region.bottom <= crop_region.top:
raise ValueError("crop_region right/bottom must be greater than left/top.")
if crop_region.right > source_width or crop_region.bottom > source_height:
raise ValueError("crop_region must fit within the source image.")
+12 -83
View File
@@ -15,6 +15,12 @@ import torch
from PIL import Image, ImageDraw
from ..domain.segs import CropRegion
from .detail_preview_images import (
detail_alpha_mask,
image_tensor_to_rgb_pil,
normalize_mask_tensor,
validate_crop_region,
)
DETAIL_PREVIEW_WASH_OPACITY = 0.55
DETAIL_PREVIEW_OUTLINE_RGB = (255, 0, 0)
@@ -126,7 +132,7 @@ class DetailPreviewCompositor:
) -> DetailPreviewCompositor:
"""Create a compositor with detailer background work precomputed."""
source_image = _image_tensor_to_rgb_pil(context.image)
source_image = image_tensor_to_rgb_pil(context.image)
sampled_region = context.sampled_region or context.work_region
geometry = build_detail_preview_geometry(
source_width=source_image.width,
@@ -147,7 +153,7 @@ class DetailPreviewCompositor:
)
left, top, right, bottom = geometry.crop_box
crop_size = (max(1, right - left), max(1, bottom - top))
detail_alpha_mask = _detail_alpha_mask(
detail_alpha = detail_alpha_mask(
context.work_mask,
source_size=(source_image.width, source_image.height),
preview_size=geometry.preview_size,
@@ -157,7 +163,7 @@ class DetailPreviewCompositor:
return cls(
geometry=geometry,
washed_background=washed_background,
detail_alpha_mask=detail_alpha_mask,
detail_alpha_mask=detail_alpha,
)
def compose(self, crop_preview: Image.Image) -> Image.Image:
@@ -211,9 +217,9 @@ def build_detail_preview_geometry(
source_height,
max_preview_resolution,
)
_validate_crop_region(crop_region, source_width, source_height)
validate_crop_region(crop_region, source_width, source_height)
resolved_outline_region = outline_region or crop_region
_validate_crop_region(resolved_outline_region, source_width, source_height)
validate_crop_region(resolved_outline_region, source_width, source_height)
preview_width, preview_height = preview_size
scale_x = float(preview_width) / float(source_width)
@@ -238,7 +244,7 @@ def build_detail_preview_geometry(
def work_region_from_mask(mask: torch.Tensor) -> CropRegion:
"""Return the tight work region around a non-empty HW or single-item BHW mask."""
working = _normalize_mask_tensor(mask)
working = normalize_mask_tensor(mask)
coordinates = torch.nonzero(working > 0, as_tuple=False)
if coordinates.numel() == 0:
raise ValueError("detail preview work mask must contain at least one pixel.")
@@ -367,83 +373,6 @@ def _source_outline_box(
)
def _image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image:
"""Convert a single-image BHWC tensor to an RGB PIL image."""
import numpy as np
if image.ndim != 4:
raise ValueError("detail preview image must be a BHWC tensor.")
if int(image.shape[0]) != 1:
raise ValueError("detail preview image must contain exactly one image.")
if int(image.shape[-1]) < 1:
raise ValueError("detail preview image must contain at least one channel.")
array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy()
if array.shape[-1] == 1:
array = np.repeat(array, 3, axis=-1)
elif array.shape[-1] >= 3:
array = array[..., :3]
else:
array = np.repeat(array[..., :1], 3, axis=-1)
return Image.fromarray((array * 255.0).round().astype(np.uint8))
def _mask_tensor_to_l_pil(mask: torch.Tensor) -> Image.Image:
"""Convert an HW or single-item BHW mask tensor to a grayscale alpha image."""
import numpy as np
working = _normalize_mask_tensor(mask).detach().cpu().clamp(0.0, 1.0)
return Image.fromarray((working.numpy() * 255.0).round().astype(np.uint8))
def _normalize_mask_tensor(mask: torch.Tensor) -> torch.Tensor:
"""Normalize an HW or single-item BHW mask tensor to HW float."""
working = mask.detach().float()
if working.ndim == 3 and int(working.shape[0]) == 1:
working = working[0]
if working.ndim != 2:
raise ValueError("detail preview work mask must be an HW tensor.")
return working
def _detail_alpha_mask(
mask: torch.Tensor,
*,
source_size: tuple[int, int],
preview_size: tuple[int, int],
sampled_box: CropBox,
target_size: tuple[int, int],
) -> Image.Image:
"""Return an alpha mask aligned to the sampled preview paste box."""
mask_image = _mask_tensor_to_l_pil(mask)
if mask_image.size == source_size:
preview_mask = mask_image.resize(preview_size, Image.Resampling.BILINEAR)
return preview_mask.crop(sampled_box).resize(
target_size,
Image.Resampling.BILINEAR,
)
return mask_image.resize(target_size, Image.Resampling.BILINEAR)
def _validate_crop_region(
crop_region: CropRegion,
source_width: int,
source_height: int,
) -> None:
"""Reject crop regions that cannot be mapped into the source image."""
if crop_region.left < 0 or crop_region.top < 0:
raise ValueError("crop_region left and top must be non-negative.")
if crop_region.right <= crop_region.left or crop_region.bottom <= crop_region.top:
raise ValueError("crop_region right/bottom must be greater than left/top.")
if crop_region.right > source_width or crop_region.bottom > source_height:
raise ValueError("crop_region must fit within the source image.")
def _validate_positive_int(name: str, value: int) -> None:
"""Reject non-positive integer values."""
+11 -9
View File
@@ -14,6 +14,7 @@ import torch
from . import sampling_samplers, sampling_schedulers
from .detail_previews import DetailPreviewContext, prepare_detail_preview_callback
from .differential_diffusion import clone_with_differential_diffusion
from .guided_sampling import sample_with_optional_negative
Latent: TypeAlias = dict[str, Any]
@@ -86,15 +87,16 @@ class DetailSampler:
callback = _latent_preview().prepare_callback(model, steps)
else:
callback = prepare_detail_preview_callback(model, steps, preview_context)
samples = comfy_sample.sample_custom(
model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_samples,
samples = sample_with_optional_negative(
comfy_sample=comfy_sample,
model=model,
noise=noise,
cfg=cfg,
sampler=sampler,
sigmas=sigmas,
positive=positive,
negative=negative,
latent_image=latent_samples,
noise_mask=noise_mask,
callback=callback,
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
@@ -0,0 +1,65 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Inspect tensor-derived metadata exposed by loaded ComfyUI diffusion models."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
@dataclass(frozen=True)
class DiffusionModelMetadata:
"""Describe architecture fields relevant to component selection."""
image_model: str | None
context_input_dimension: int | None
@runtime_checkable
class ModelPatcherBoundary(Protocol):
"""Expose the loaded model objects required for architecture inspection."""
def get_model_object(self, name: str) -> object:
"""Return a named object owned by ComfyUI's model patcher."""
class DiffusionModelMetadataInspector:
"""Read normalized architecture metadata from a loaded model patcher."""
def inspect(self, model: object) -> DiffusionModelMetadata | None:
"""Return narrowed model metadata or None when it is unavailable."""
if not isinstance(model, ModelPatcherBoundary):
return None
try:
model_config = model.get_model_object("model_config")
except (AttributeError, KeyError, TypeError, ValueError):
LOGGER.warning(
"loaded model does not expose inspectable model configuration"
)
return None
unet_config = getattr(model_config, "unet_config", None)
if not isinstance(unet_config, Mapping):
return None
image_model_value = unet_config.get("image_model")
context_dimension_value = unet_config.get("context_in_dim")
return DiffusionModelMetadata(
image_model=(
image_model_value if isinstance(image_model_value, str) else None
),
context_input_dimension=(
context_dimension_value
if isinstance(context_dimension_value, int)
and not isinstance(context_dimension_value, bool)
else None
),
)
+1 -1
View File
@@ -13,7 +13,7 @@ import torch
from PIL import Image
from ..domain.segs import Segment
from ..masking.segs_mask_ops import crop_image, crop_mask, resize_mask
from ..domain.segs_mask_ops import crop_image, crop_mask, resize_mask
from ..shared.tensor_validation import validate_image_tensor
SEG_IMAGE_MODES = ("transparent mask", "black mask", "full crop")
+7
View File
@@ -21,6 +21,7 @@ FLUX_CLIP_L = AutoModelArtifact(
source_repo="comfyanonymous/flux_text_encoders",
description="FLUX CLIP-L text encoder",
sha256="660c6f5b1abae9dc498ac2d21e1347d2abdb0cf6c0c0c8576cd796491d9a6cdd",
file_size_bytes=246_144_152,
)
FLUX_T5_XXL = AutoModelArtifact(
@@ -35,6 +36,7 @@ FLUX_T5_XXL = AutoModelArtifact(
source_repo="comfyanonymous/flux_text_encoders",
description="FLUX T5-XXL FP16 text encoder",
sha256="6e480b09fae049a72d2a8c5fbccb8d3e92febeb233bbe9dfe7256958a9167635",
file_size_bytes=9_787_841_024,
)
FLUX_VAE = AutoModelArtifact(
@@ -50,6 +52,7 @@ FLUX_VAE = AutoModelArtifact(
source_repo="Comfy-Org/Lumina_Image_2.0_Repackaged",
description="FLUX autoencoder VAE",
sha256="afc8e28272cd15db3919bacdb6918ce9c1ed22e96cb12c4d5ed0fba823529e38",
file_size_bytes=335_304_388,
)
FLUX2_DEV_TEXT_ENCODER = AutoModelArtifact(
@@ -65,6 +68,7 @@ FLUX2_DEV_TEXT_ENCODER = AutoModelArtifact(
source_repo="Comfy-Org/flux2-dev",
description="FLUX.2 dev Mistral 3 Small text encoder",
sha256="7d79902f60b1aeb3a6de2cfad02f4367b5e300a1387de3d03ac717cfa3df117c",
file_size_bytes=35_584_897_447,
)
FLUX2_KLEIN_4B_TEXT_ENCODER = AutoModelArtifact(
@@ -80,6 +84,7 @@ FLUX2_KLEIN_4B_TEXT_ENCODER = AutoModelArtifact(
source_repo="Comfy-Org/vae-text-encorder-for-flux-klein-4b",
description="FLUX.2 Klein 4B Qwen3 text encoder",
sha256="6c671498573ac2f7a5501502ccce8d2b08ea6ca2f661c458e708f36b36edfc5a",
file_size_bytes=8_044_982_048,
)
FLUX2_KLEIN_9B_TEXT_ENCODER = AutoModelArtifact(
@@ -95,6 +100,7 @@ FLUX2_KLEIN_9B_TEXT_ENCODER = AutoModelArtifact(
source_repo="Comfy-Org/vae-text-encorder-for-flux-klein-9b",
description="FLUX.2 Klein 9B Qwen3 8B FP8-mixed text encoder",
sha256="abad16806e0cbabc54e0325d6565847443fe396d5f0be38bb3cd3fe75a1201d6",
file_size_bytes=8_664_848_742,
)
FLUX2_VAE = AutoModelArtifact(
@@ -110,6 +116,7 @@ FLUX2_VAE = AutoModelArtifact(
source_repo="Comfy-Org/flux2-dev",
description="FLUX.2 VAE",
sha256="d64f3a68e1cc4f9f4e29b6e0da38a0204fe9a49f2d4053f0ec1fa1ca02f9c4b5",
file_size_bytes=336_213_556,
)
FLUX2_TEXT_ENCODERS: dict[Flux2TextEncoderProfile, AutoModelArtifact] = {
+27 -34
View File
@@ -6,49 +6,42 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Protocol, runtime_checkable
from typing import Protocol
from ..domain.flux_profiles import FluxModelProfile, classify_flux_profile
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
@runtime_checkable
class ModelPatcherBoundary(Protocol):
"""Expose the loaded model objects required for architecture inspection."""
def get_model_object(self, name: str) -> object:
"""Return a named object owned by ComfyUI's model patcher."""
from .diffusion_model_metadata import (
DiffusionModelMetadata,
DiffusionModelMetadataInspector,
)
class FluxModelInspector:
"""Read ComfyUI's tensor-derived model configuration after model loading."""
def __init__(
self,
metadata_inspector: DiffusionModelMetadataInspectorBoundary | None = None,
) -> None:
"""Create a FLUX classifier over shared metadata inspection."""
self._metadata_inspector = (
metadata_inspector or DiffusionModelMetadataInspector()
)
def inspect(self, model: object) -> FluxModelProfile | None:
"""Return a detected FLUX profile or None for unavailable metadata."""
if not isinstance(model, ModelPatcherBoundary):
metadata = self._metadata_inspector.inspect(model)
if metadata is None:
return None
try:
model_config = model.get_model_object("model_config")
except (AttributeError, KeyError, TypeError, ValueError):
LOGGER.warning(
"loaded model does not expose inspectable model configuration"
)
return None
unet_config = getattr(model_config, "unet_config", None)
if not isinstance(unet_config, Mapping):
return None
image_model_value = unet_config.get("image_model")
context_dimension_value = unet_config.get("context_in_dim")
image_model = image_model_value if isinstance(image_model_value, str) else None
context_dimension = (
context_dimension_value
if isinstance(context_dimension_value, int)
and not isinstance(context_dimension_value, bool)
else None
return classify_flux_profile(
metadata.image_model,
metadata.context_input_dimension,
)
return classify_flux_profile(image_model, context_dimension)
class DiffusionModelMetadataInspectorBoundary(Protocol):
"""Expose normalized loaded diffusion-model metadata."""
def inspect(self, model: object) -> DiffusionModelMetadata | None:
"""Return normalized metadata when available."""
@@ -0,0 +1,74 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Compose global-first conditioning hooks for conventional regional sampling."""
from __future__ import annotations
from typing import Any, TypeAlias
from comfy.hooks import HookGroup
from .regional_lora_conditioning_sources import conditioning_hook_groups
Conditioning: TypeAlias = list[list[Any]]
class GlobalFirstConditioningHookComposer:
"""Apply one global HookGroup to every regional conditioning model state."""
def global_hooks(
self,
conditioning: Conditioning,
*,
source_label: str,
) -> HookGroup | None:
"""Return the single uniform HookGroup carried by global conditioning."""
groups = conditioning_hook_groups(conditioning)
if not groups:
return None
authority = groups[0]
if any(group is not authority for group in groups[1:]):
raise ValueError(
f"{source_label} uses different HookGroups across conditioning "
"entries. Keep one shared Prompt Control hook schedule on the "
"global segment."
)
return authority
def compose(
self,
conditioning: Conditioning,
global_hooks: HookGroup | None,
*,
source_label: str,
cache: dict[tuple[HookGroup, HookGroup], HookGroup],
) -> Conditioning:
"""Prepend global hooks to every local HookGroup without mutating inputs."""
if global_hooks is None:
return [[item[0], dict(item[1])] for item in conditioning]
composed: Conditioning = []
for item_index, item in enumerate(conditioning):
metadata = dict(item[1])
local_hooks = metadata.get("hooks")
if local_hooks is None:
metadata["hooks"] = global_hooks
elif not isinstance(local_hooks, HookGroup):
raise TypeError(
f"{source_label} item {item_index} hooks must be a Comfy HookGroup."
)
else:
key = (global_hooks, local_hooks)
combined = cache.get(key)
if combined is None:
combined = global_hooks.clone_and_combine(local_hooks)
cache[key] = combined
metadata["hooks"] = combined
composed.append([item[0], metadata])
return composed
GLOBAL_FIRST_CONDITIONING_HOOK_COMPOSER = GlobalFirstConditioningHookComposer()
@@ -0,0 +1,63 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Resolve the effective Comfy MODEL before global conditioning hooks execute."""
from __future__ import annotations
from typing import cast
from ..domain.conditioning_batch import select_conditioning
from .regional_lora_conditioning_sources import conditioning_hook_groups
class GlobalHookModelResolver:
"""Mirror Comfy's dynamic-to-static handoff before regional derivation."""
def resolve(
self,
model: object,
*,
positive: object,
negative: object,
) -> object:
"""Return the model that Comfy will use for global hooked conditioning."""
if not self._has_global_hooks(positive) and not self._has_global_hooks(
negative
):
return model
is_dynamic = getattr(model, "is_dynamic", None)
if not callable(is_dynamic):
raise TypeError(
"Global conditioning hooks require MODEL dynamic-mode state."
)
dynamic = is_dynamic()
if not isinstance(dynamic, bool):
raise TypeError("MODEL is_dynamic() must return a bool.")
if not dynamic:
return model
delegate_factory = getattr(model, "get_non_dynamic_delegate", None)
if not callable(delegate_factory):
raise TypeError(
"Dynamic MODEL global conditioning hooks require Comfy's "
"get_non_dynamic_delegate()."
)
resolved = delegate_factory()
if resolved is model:
raise RuntimeError("Dynamic MODEL returned itself as its static delegate.")
resolved_is_dynamic = getattr(resolved, "is_dynamic", None)
if not callable(resolved_is_dynamic) or resolved_is_dynamic() is not False:
raise RuntimeError("Global conditioning hook delegate must be static.")
return cast(object, resolved)
@staticmethod
def _has_global_hooks(conditioning: object) -> bool:
"""Report hooks only on consumer-defined global entry zero."""
global_conditioning = select_conditioning(conditioning, 0)
return bool(conditioning_hook_groups(global_conditioning))
GLOBAL_HOOK_MODEL_RESOLVER = GlobalHookModelResolver()
+83
View File
@@ -0,0 +1,83 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Select ComfyUI's CFG or positive-only guider for latent sampling."""
from __future__ import annotations
from importlib import import_module
from typing import Any, cast
import torch
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
def sample_with_optional_negative(
*,
comfy_sample: Any,
model: Any,
noise: torch.Tensor,
cfg: float,
sampler: Any,
sigmas: torch.Tensor,
positive: Any,
negative: Any | None,
latent_image: torch.Tensor,
noise_mask: Any = None,
callback: Any = None,
disable_pbar: bool = False,
seed: int | None = None,
) -> torch.Tensor:
"""Sample with CFG when negative exists or Comfy's positive-only path otherwise."""
if negative is not None:
return cast(
torch.Tensor,
comfy_sample.sample_custom(
model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_image,
noise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
),
)
comfy_samplers = import_module("comfy.samplers")
model_management = import_module("comfy.model_management")
guider = comfy_samplers.CFGGuider(model)
guider.inner_set_conds({"positive": positive})
samples = guider.sample(
noise,
latent_image,
sampler,
sigmas,
denoise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=seed,
)
LOGGER.debug(
"Positive-only ComfyUI guider selected",
extra={
"operation": "sample_with_optional_negative",
"guidance_mode": "positive_only",
},
)
return cast(
torch.Tensor,
samples.to(
device=model_management.intermediate_device(),
dtype=model_management.intermediate_dtype(),
),
)
+56
View File
@@ -0,0 +1,56 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Declare trusted automatic artifacts and selections for Krea 2."""
from __future__ import annotations
from .auto_model_artifact import AutoModelArtifact
KREA2_AUTO_TEXT_ENCODER = "auto"
KREA2_QWEN3_VL_4B_FP8 = AutoModelArtifact(
cache_id="krea2_qwen3vl_4b_fp8_scaled",
filename="qwen3vl_4b_fp8_scaled.safetensors",
folder_name="text_encoders",
canonical_subfolder="krea2",
source_url=(
"https://huggingface.co/Comfy-Org/Krea-2/resolve/"
"e5ea8b4dd7f38f348b138eb0fe29f92c0e367e96/text_encoders/"
"qwen3vl_4b_fp8_scaled.safetensors"
),
source_repo="Comfy-Org/Krea-2",
description="Krea 2 Qwen3-VL 4B FP8-scaled text encoder",
sha256="54bd5144df0bbc25dd6ccadfcb826b521445a1b06ae5a42570bdd2974ca87094",
file_size_bytes=5_242_467_968,
)
KREA2_QWEN3_VL_4B_BF16 = AutoModelArtifact(
cache_id="krea2_qwen3vl_4b_bf16",
filename="qwen3vl_4b_bf16.safetensors",
folder_name="text_encoders",
canonical_subfolder="krea2",
source_url=(
"https://huggingface.co/Comfy-Org/Krea-2/resolve/"
"e5ea8b4dd7f38f348b138eb0fe29f92c0e367e96/text_encoders/"
"qwen3vl_4b_bf16.safetensors"
),
source_repo="Comfy-Org/Krea-2",
description="Krea 2 Qwen3-VL 4B BF16 text encoder",
sha256="36f3ff447ef59201722e8f9ce6020c9819fdcfba6aa2608c4e09b1c0ce114e34",
file_size_bytes=8_875_719_384,
)
KREA2_TEXT_ENCODER_ARTIFACTS = {
KREA2_QWEN3_VL_4B_FP8.filename: KREA2_QWEN3_VL_4B_FP8,
KREA2_QWEN3_VL_4B_BF16.filename: KREA2_QWEN3_VL_4B_BF16,
}
__all__ = [
"KREA2_AUTO_TEXT_ENCODER",
"KREA2_QWEN3_VL_4B_BF16",
"KREA2_QWEN3_VL_4B_FP8",
"KREA2_TEXT_ENCODER_ARTIFACTS",
]
@@ -31,6 +31,7 @@ from .differential_diffusion import (
differential_diffusion_mutation,
has_denoise_mask_function,
)
from .guided_sampling import sample_with_optional_negative
from .model_patcher_mutations import ModelUnetWrapperMutation
from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
from .sampling_model_types import (
@@ -137,15 +138,16 @@ def sample_mixture_of_diffusers(
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
noise_mask = latent_image.get("noise_mask", None)
callback = _sampling_callback(sampling_model, steps, preview_context)
samples = comfy_sample.sample_custom(
sampling_model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_samples,
samples = sample_with_optional_negative(
comfy_sample=comfy_sample,
model=sampling_model,
noise=noise,
cfg=cfg,
sampler=sampler,
sigmas=sigmas,
positive=positive,
negative=negative,
latent_image=latent_samples,
noise_mask=noise_mask,
callback=callback,
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
+308 -2
View File
@@ -2,7 +2,7 @@
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Known model metadata for grounded SAM masking."""
"""Known model metadata for downloadable SimpleSyrup model loaders."""
from __future__ import annotations
@@ -11,13 +11,14 @@ from enum import StrEnum
class ModelFamily(StrEnum):
"""Catalog families used by grounded SAM model selection."""
"""Catalog families used by SimpleSyrup model selection."""
SAM = "sam"
GROUNDING_DINO = "grounding_dino"
TEXT_ENCODER = "text_encoder"
VITMATTE = "vitmatte"
WD14_TAGGER = "wd14_tagger"
ULTRALYTICS = "ultralytics"
@dataclass(frozen=True)
@@ -29,6 +30,7 @@ class ModelArtifact:
folder_name: str
source_url: str
description: str
sha256: str | None = None
@dataclass(frozen=True)
@@ -386,6 +388,298 @@ WD14_TAGGER_ENTRIES: tuple[ModelEntry, ...] = (
)
_ANZHCS_YOLOS_REVISION = "f5a2306d7fed4f3cfc26c25ff1ab2e3f3cfce855"
_ANZHCS_YOLOS_REPOSITORY = "Anzhc/Anzhcs_YOLOs"
def _huggingface_yolo_entry(
*,
entry_id: str,
display_name: str,
filename: str,
folder_name: str,
model_type: str,
source_repo: str,
revision: str,
license_note: str,
description: str,
sha256: str,
) -> ModelEntry:
"""Build one revision-pinned Hugging Face Ultralytics catalog entry."""
encoded_filename = filename.replace(" ", "%20")
return ModelEntry(
entry_id=entry_id,
display_name=display_name,
family=ModelFamily.ULTRALYTICS,
model_type=model_type,
source_repo=source_repo,
license_note=license_note,
artifacts=(
ModelArtifact(
artifact_id=f"{entry_id}_checkpoint",
filename=filename,
folder_name=folder_name,
source_url=(
f"https://huggingface.co/{source_repo}/resolve/{revision}/"
f"{encoded_filename}"
),
description=description,
sha256=sha256,
),
),
)
def _anzhc_yolo_entry(
*,
entry_id: str,
display_name: str,
filename: str,
folder_name: str,
model_type: str,
description: str,
sha256: str,
) -> ModelEntry:
"""Build one revision-pinned Anzhc Ultralytics catalog entry."""
return _huggingface_yolo_entry(
entry_id=entry_id,
display_name=display_name,
filename=filename,
folder_name=folder_name,
model_type=model_type,
source_repo=_ANZHCS_YOLOS_REPOSITORY,
revision=_ANZHCS_YOLOS_REVISION,
license_note="AGPL-3.0",
description=description,
sha256=sha256,
)
ULTRALYTICS_ENTRIES: tuple[ModelEntry, ...] = (
_anzhc_yolo_entry(
entry_id="anzhc_face_seg",
display_name="Anzhc Face -seg (6.52MB)",
filename="Anzhc Face -seg.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc face segmentation model",
sha256="dbf083201298a495e332113de0612d1be1ae8307628628eb7972a31979cdbbb3",
),
_anzhc_yolo_entry(
entry_id="anzhc_face_seg_640_v2_y8n",
display_name="Anzhc Face seg 640 v2 y8n (6.56MB)",
filename="Anzhc Face seg 640 v2 y8n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc face segmentation model",
sha256="d473e8bccc4c833d8eb36c95e566ce6460ffdc8b2899c859910e380c85def276",
),
_anzhc_yolo_entry(
entry_id="anzhc_face_seg_768_v2_y8n",
display_name="Anzhc Face seg 768 v2 y8n (6.58MB)",
filename="Anzhc Face seg 768 v2 y8n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc face segmentation model",
sha256="9a1e5b154c1d190812447431bda6b8f260f132877812b4a2f163981f54558355",
),
_anzhc_yolo_entry(
entry_id="anzhc_face_seg_768ms_v2_y8n",
display_name="Anzhc Face seg 768MS v2 y8n (6.60MB)",
filename="Anzhc Face seg 768MS v2 y8n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc multi-scale face segmentation model",
sha256="429e88d9aecb9fa4167ffd41a6ebc42c97b7fa785aa5468a7eb302ceb9837aae",
),
_anzhc_yolo_entry(
entry_id="anzhc_face_seg_1024_v2_y8n",
display_name="Anzhc Face seg 1024 v2 y8n (6.63MB)",
filename="Anzhc Face seg 1024 v2 y8n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc face segmentation model",
sha256="1bbcfd7a9f407c6f6e4389a371dbcc392f9444421cf7f824152e92bf563dc6a3",
),
_anzhc_yolo_entry(
entry_id="anzhc_face_seg_640_v3_y11n",
display_name="Anzhc Face seg 640 v3 y11n (5.80MB)",
filename="Anzhc Face seg 640 v3 y11n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc YOLO11 face segmentation model",
sha256="96437afc773bacd118e275e6cddc1fb7263c78dc11299989c7a00a26506c45bf",
),
_anzhc_yolo_entry(
entry_id="anzhc_face_seg_640_v4_y11n",
display_name="Anzhc Face seg 640 v4 y11n (5.74MB)",
filename="Anzhc Face seg 640 v4 y11n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc YOLO11 face segmentation model",
sha256="1e77ad7bd349babd8a4a90478bfc965348642b63a8d95d3b43ee13db42fd0a64",
),
_anzhc_yolo_entry(
entry_id="anzhcs_manface_v02_1024_y8n",
display_name="Anzhcs ManFace v02 1024 y8n (6.06MB)",
filename="Anzhcs ManFace v02 1024 y8n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc male face segmentation model",
sha256="184b9a680afb3c4a559e46e2fe692338fe7bdd6267979fa4ef10526fa96c1b31",
),
_anzhc_yolo_entry(
entry_id="anzhcs_womanface_v05_1024_y8n",
display_name="Anzhcs WomanFace v05 1024 y8n (6.07MB)",
filename="Anzhcs WomanFace v05 1024 y8n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc female face segmentation model",
sha256="84db37616e1ca975c4e23fa5a300acf0edd9144ec287bbbdbd1ad0f4a3afa9c1",
),
_anzhc_yolo_entry(
entry_id="anzhc_eyes_seg_hd",
display_name="Anzhc Eyes -seg-hd (6.59MB)",
filename="Anzhc Eyes -seg-hd.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc eye segmentation model",
sha256="6be1c13ca7a51c2425e278e07e7ae3d4c94ee125b874a0104a142f4f5a35a308",
),
_anzhc_yolo_entry(
entry_id="anzhc_headhair_seg_y8n",
display_name="Anzhc HeadHair seg y8n (6.50MB)",
filename="Anzhc HeadHair seg y8n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc head and hair segmentation model",
sha256="a6e99b1305f600c35e7f6400741c2322b198ae03755f91dc1c59d7a78d77f13c",
),
_anzhc_yolo_entry(
entry_id="anzhc_headhair_seg_y8m",
display_name="Anzhc HeadHair seg y8m (52.34MB)",
filename="Anzhc HeadHair seg y8m.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc head and hair segmentation model",
sha256="f63aa1cdb63a26c0025a4a984588248241a5838aff4edfeea93d9c155efe0b5e",
),
_anzhc_yolo_entry(
entry_id="anzhc_breasts_seg_v1_1024n",
display_name="Anzhc Breasts Seg v1 1024n (6.58MB)",
filename="Anzhc Breasts Seg v1 1024n.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc breast segmentation model",
sha256="d469bd7abdcbe32a946e0e342bc1fe96aa021987787d51245f97a29e114cb31b",
),
_anzhc_yolo_entry(
entry_id="anzhc_breasts_seg_v1_1024s",
display_name="Anzhc Breasts Seg v1 1024s (22.86MB)",
filename="Anzhc Breasts Seg v1 1024s.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc breast segmentation model",
sha256="413a9b948a40f96a83769a882816ef0dd2b91b49673c91bff75463660077b395",
),
_anzhc_yolo_entry(
entry_id="anzhc_breasts_seg_v1_1024m",
display_name="Anzhc Breasts Seg v1 1024m (52.39MB)",
filename="Anzhc Breasts Seg v1 1024m.pt",
folder_name="ultralytics_segm",
model_type="segment",
description="Anzhc breast segmentation model",
sha256="53d15e82a8308f8056f4929838e00e42c8da576b661e0c2b4fef5837d8b5b2b4",
),
_huggingface_yolo_entry(
entry_id="bingsu_face_yolov8n_v2",
display_name="Bingsu Face YOLOv8n v2 (6.23MB)",
filename="face_yolov8n_v2.pt",
folder_name="ultralytics_bbox",
model_type="detect",
source_repo="Bingsu/adetailer",
revision="53cc19de382014514d9d4038601d261a7faa9b7b",
license_note="Apache-2.0",
description="Bingsu ADetailer face detection model",
sha256="8f5f2110f83c4e00712993fab48c771d26036e2e80ec62bd5b9cb37c29e36b36",
),
_huggingface_yolo_entry(
entry_id="bingsu_face_yolov8s",
display_name="Bingsu Face YOLOv8s (22.5MB)",
filename="face_yolov8s.pt",
folder_name="ultralytics_bbox",
model_type="detect",
source_repo="Bingsu/adetailer",
revision="53cc19de382014514d9d4038601d261a7faa9b7b",
license_note="Apache-2.0",
description="Bingsu ADetailer face detection model",
sha256="c7237eff25787377de196961140ceaed324d859ee8de5a775d93d33a0e3fab78",
),
_huggingface_yolo_entry(
entry_id="bingsu_hand_yolov8n",
display_name="Bingsu Hand YOLOv8n (6.23MB)",
filename="hand_yolov8n.pt",
folder_name="ultralytics_bbox",
model_type="detect",
source_repo="Bingsu/adetailer",
revision="53cc19de382014514d9d4038601d261a7faa9b7b",
license_note="Apache-2.0",
description="Bingsu ADetailer hand detection model",
sha256="3991202eb69e9ddcb3b9ba80cdeb41e734ffaf844403d6c9f47d515cd88c6f29",
),
_huggingface_yolo_entry(
entry_id="bingsu_hand_yolov8s",
display_name="Bingsu Hand YOLOv8s (22.5MB)",
filename="hand_yolov8s.pt",
folder_name="ultralytics_bbox",
model_type="detect",
source_repo="Bingsu/adetailer",
revision="53cc19de382014514d9d4038601d261a7faa9b7b",
license_note="Apache-2.0",
description="Bingsu ADetailer hand detection model",
sha256="70b540063fbc385736d8258970744a4afbc4cbf7932134bae3b24cdadeadec06",
),
_huggingface_yolo_entry(
entry_id="bingsu_person_yolov8n_seg",
display_name="Bingsu Person YOLOv8n-seg (6.78MB)",
filename="person_yolov8n-seg.pt",
folder_name="ultralytics_segm",
model_type="segment",
source_repo="Bingsu/adetailer",
revision="53cc19de382014514d9d4038601d261a7faa9b7b",
license_note="Apache-2.0",
description="Bingsu ADetailer person segmentation model",
sha256="38fc8aaae97cb6e70be4ec44770005b26ed473471362afcda62a0037d7ccf432",
),
_huggingface_yolo_entry(
entry_id="bingsu_person_yolov8s_seg",
display_name="Bingsu Person YOLOv8s-seg (23.9MB)",
filename="person_yolov8s-seg.pt",
folder_name="ultralytics_segm",
model_type="segment",
source_repo="Bingsu/adetailer",
revision="53cc19de382014514d9d4038601d261a7faa9b7b",
license_note="Apache-2.0",
description="Bingsu ADetailer person segmentation model",
sha256="53c54aec2239355faffc6c5b70d0f3d05042f386f956cbec39cec46ad456f050",
),
_huggingface_yolo_entry(
entry_id="fuyucchi_yolov8x6_animeface",
display_name="Fuyucchi YOLOv8x6 Anime Face (195MB)",
filename="yolov8x6_animeface.pt",
folder_name="ultralytics_bbox",
model_type="detect",
source_repo="Fuyucchi/yolov8_animeface",
revision="b0841ce930453c0f23ceb8086d6554c17de5fe4a",
license_note="AGPL-3.0",
description="Fuyucchi high-resolution anime face detection model",
sha256="f3cdc1a6266347322439fd9b3c8f5a1222668eb10c8adf00e17b28c48b95213c",
),
)
def sam_choices() -> list[str]:
"""Return deterministic SAM dropdown choices."""
@@ -410,6 +704,12 @@ def wd14_tagger_choices() -> list[str]:
return [entry.display_name for entry in WD14_TAGGER_ENTRIES]
def ultralytics_choices() -> list[str]:
"""Return deterministic Ultralytics dropdown choices."""
return [entry.display_name for entry in ULTRALYTICS_ENTRIES]
def get_sam_entry(selection: str) -> ModelEntry:
"""Return the SAM catalog entry matching an id or display name."""
@@ -434,6 +734,12 @@ def get_wd14_tagger_entry(selection: str) -> ModelEntry:
return _get_entry(selection, WD14_TAGGER_ENTRIES, "WD14 tagger")
def get_ultralytics_entry(selection: str) -> ModelEntry:
"""Return the Ultralytics catalog entry matching an id or display name."""
return _get_entry(selection, ULTRALYTICS_ENTRIES, "Ultralytics")
def _get_entry(
selection: str,
entries: tuple[ModelEntry, ...],
+18 -64
View File
@@ -6,24 +6,17 @@
from __future__ import annotations
from types import ModuleType
from typing import Protocol
from .model_catalog import (
GROUNDING_DINO_ENTRIES,
SAM_ENTRIES,
VITMATTE_ENTRIES,
WD14_TAGGER_ENTRIES,
ModelEntry,
grounding_dino_choices,
sam_choices,
ultralytics_choices,
vitmatte_choices,
wd14_tagger_choices,
)
from .model_folders import resolve_model_file
from .settings import SimpleSyrupSettings
from .settings_repository import SimpleSyrupSettingsRepository
from .vitmatte_loader import ViTMatteLoaderService
NO_LOCAL_SAM_MODELS = "No local SAM models found"
NO_LOCAL_GROUNDING_DINO_MODELS = "No local GroundingDINO models found"
@@ -44,69 +37,52 @@ class ModelChoiceService:
def __init__(
self,
settings_repository: SettingsProvider | None = None,
folder_paths_module: ModuleType | None = None,
) -> None:
"""Create the choice service with injectable external boundaries."""
"""Create the choice service with an injectable settings boundary."""
self._settings_repository = (
settings_repository or SimpleSyrupSettingsRepository()
)
self._folder_paths_module = folder_paths_module
self._vitmatte_loader = ViTMatteLoaderService(
folder_paths_module=folder_paths_module
)
def sam_choices(self) -> list[str]:
"""Return settings-aware SAM dropdown choices."""
"""Return curated SAM choices when catalog models are visible."""
if self._show_downloadable_models():
return sam_choices()
choices = [
entry.display_name
for entry in SAM_ENTRIES
if self._entry_artifacts_are_local(entry)
]
return choices or [NO_LOCAL_SAM_MODELS]
return [NO_LOCAL_SAM_MODELS]
def grounding_dino_choices(self) -> list[str]:
"""Return settings-aware GroundingDINO dropdown choices."""
"""Return curated GroundingDINO choices when catalog models are visible."""
if self._show_downloadable_models():
return grounding_dino_choices()
choices = [
entry.display_name
for entry in GROUNDING_DINO_ENTRIES
if self._entry_artifacts_are_local(entry)
]
return choices or [NO_LOCAL_GROUNDING_DINO_MODELS]
return [NO_LOCAL_GROUNDING_DINO_MODELS]
def vitmatte_choices(self) -> list[str]:
"""Return settings-aware ViTMatte dropdown choices."""
"""Return curated ViTMatte choices when catalog models are visible."""
if self._show_downloadable_models():
return vitmatte_choices()
choices = [
entry.display_name
for entry in VITMATTE_ENTRIES
if self._vitmatte_entry_is_local(entry)
]
return choices or [NO_LOCAL_VITMATTE_MODELS]
return [NO_LOCAL_VITMATTE_MODELS]
def wd14_tagger_choices(self) -> list[str]:
"""Return settings-aware WD14 tagger dropdown choices."""
"""Return curated WD14 tagger choices when catalog models are visible."""
if self._show_downloadable_models():
return wd14_tagger_choices()
choices = [
entry.display_name
for entry in WD14_TAGGER_ENTRIES
if self._entry_artifacts_are_local(entry)
]
return choices or [NO_LOCAL_WD14_TAGGER_MODELS]
return [NO_LOCAL_WD14_TAGGER_MODELS]
def ultralytics_choices(self) -> list[str]:
"""Return curated Ultralytics choices when catalog models are visible."""
if self._show_downloadable_models():
return ultralytics_choices()
return []
def reject_sentinel(self, selection: str) -> None:
"""Reject placeholder dropdown selections before loader work begins."""
@@ -143,28 +119,6 @@ class ModelChoiceService:
return self._settings_repository.load().show_downloadable_models
def _entry_artifacts_are_local(self, entry: ModelEntry) -> bool:
"""Return whether every catalog artifact exists locally."""
return all(
resolve_model_file(
artifact.folder_name,
artifact.filename,
self._folder_paths_module,
)
is not None
for artifact in entry.artifacts
)
def _vitmatte_entry_is_local(self, entry: ModelEntry) -> bool:
"""Return whether a valid ViTMatte directory exists locally."""
try:
self._vitmatte_loader.resolve_model_directory(entry, auto_download=False)
except FileNotFoundError:
return False
return True
def default_choice(choices: list[str], preferred: str) -> str:
"""Return the preferred default when visible, otherwise the first choice."""
+11 -10
View File
@@ -59,30 +59,30 @@ class ComfyProgressReporter:
"""Initialize an empty ComfyUI progress reporter."""
self._progress_bar: object | None = None
self._total = 1
self._total: int | None = None
def start(self, label: str, total: int | None) -> None:
"""Create a ComfyUI progress bar for one artifact."""
comfy_utils = importlib.import_module("comfy.utils")
progress_bar_class = comfy_utils.ProgressBar
self._total = total if total and total > 0 else 1
self._progress_bar = progress_bar_class(self._total)
self._total = total if total and total > 0 else None
progress_total = self._total or 1
self._progress_bar = progress_bar_class(progress_total)
self.advance(0, total)
LOGGER.info("download progress started", extra={"label": label, "total": total})
def advance(self, current: int, total: int | None) -> None:
"""Update the ComfyUI progress bar."""
"""Update known-size downloads without falsely completing unknown ones."""
if self._progress_bar is None:
return
if total and total > 0 and total != self._total:
if total is None or total <= 0:
return
if total != self._total:
self._total = total
value = (
current if total and total > 0 else min(current // CHUNK_SIZE, self._total)
)
progress_bar = cast(_ComfyProgressBar, self._progress_bar)
progress_bar.update_absolute(value, self._total)
progress_bar.update_absolute(current, self._total)
def finish(self) -> None:
"""Mark the current ComfyUI progress bar complete."""
@@ -90,7 +90,8 @@ class ComfyProgressReporter:
if self._progress_bar is None:
return
progress_bar = cast(_ComfyProgressBar, self._progress_bar)
progress_bar.update_absolute(self._total, self._total)
total = self._total or 1
progress_bar.update_absolute(total, total)
@dataclass(frozen=True)
+1
View File
@@ -78,6 +78,7 @@ class GroundedSAMModelMetadata:
"artifact_id": artifact.artifact_id,
"filename": artifact.filename,
"source_url": artifact.source_url,
"sha256": artifact.sha256,
"expected_path": str(expected),
"local_path": str(local_path) if local_path else None,
"installed": local_path is not None,
@@ -237,6 +237,102 @@ class ModelDiffusionWrapperMutation:
).apply(model)
@dataclass(frozen=True)
class ModelInteropDiffusionWrapperMutation:
"""Install the exact legacy key required for PPM Anima interoperability."""
key: str
wrapper: Callable[..., object]
def apply(self, model: object) -> None:
"""Install only the documented PPM Anima wrapper surface."""
if self.key != "ppm_negpip_anima":
raise ValueError("NegPiP interop wrapper must use PPM's Anima key.")
getter = _require_bound_method(model, "get_wrappers", ("wrapper_type", "key"))
adder = _require_bound_method(
model,
"add_wrapper_with_key",
("wrapper_type", "key", "wrapper"),
)
existing = getter(WrappersMP.DIFFUSION_MODEL, self.key)
if not isinstance(existing, list) or any(
not callable(callback) for callback in existing
):
raise TypeError("Existing NegPiP wrappers must be a callable list.")
if existing:
raise ValueError("PPM's Anima NegPiP wrapper key is already installed.")
adder(WrappersMP.DIFFUSION_MODEL, self.key, self.wrapper)
@dataclass(frozen=True)
class ModelAttentionPatchMutation:
"""Append one validated Comfy attention patch to a derived MODEL."""
patch_name: str
callback: Callable[..., object]
def apply(self, model: object) -> None:
"""Install an attn1 or attn2 callback through the public patcher setter."""
if self.patch_name not in {"attn1", "attn2"}:
raise ValueError("MODEL attention patch name must be 'attn1' or 'attn2'.")
if not callable(self.callback):
raise TypeError("MODEL attention patch callback must be callable.")
setter = getattr(model, f"set_model_{self.patch_name}_patch", None)
if not callable(setter):
raise TypeError(f"MODEL does not support {self.patch_name} patches.")
setter(self.callback)
@dataclass(frozen=True)
class ModelBooleanOptionMutation:
"""Publish one collision-safe boolean MODEL option marker."""
key: str
value: bool
def apply(self, model: object) -> None:
"""Set one supported marker only when no value already owns the key."""
if self.key != "ppm_negpip":
raise ValueError("Unsupported MODEL boolean option marker.")
if not isinstance(self.value, bool):
raise TypeError("MODEL option marker value must be boolean.")
options = _require_dictionary_attribute(model, "model_options")
if self.key in options:
raise ValueError(f"MODEL option '{self.key}' is already present.")
options[self.key] = self.value
@dataclass(frozen=True)
class ModelCallableObjectPatchMutation:
"""Replace one callable model object after collision validation."""
path: str
replacement: Callable[..., object]
def apply(self, model: object) -> None:
"""Patch one callable path without relying on bound-method identity."""
if (
not isinstance(self.path, str)
or not self.path
or any(not segment for segment in self.path.split("."))
):
raise ValueError("MODEL callable patch path must be a dotted path.")
if not callable(self.replacement):
raise TypeError("MODEL callable object replacement must be callable.")
getter = _require_bound_method(model, "get_model_object", ("name",))
adder = _require_bound_method(model, "add_object_patch", ("name", "obj"))
object_patches = _require_dictionary_attribute(model, "object_patches")
if self.path in object_patches:
raise ValueError(f"MODEL object path '{self.path}' already has a patch.")
if not callable(getter(self.path)):
raise TypeError(f"MODEL object path '{self.path}' must be callable.")
adder(self.path, self.replacement)
@dataclass(frozen=True)
class ModelExactObjectPatchMutation:
"""Replace one exact model object after collision and identity validation."""
@@ -31,6 +31,7 @@ from .differential_diffusion import (
differential_diffusion_mutation,
has_denoise_mask_function,
)
from .guided_sampling import sample_with_optional_negative
from .model_patcher_mutations import ModelUnetWrapperMutation
from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
from .sampling_model_types import (
@@ -139,15 +140,16 @@ def sample_multidiffusion(
noise = comfy_sample.prepare_noise(latent_samples, seed, batch_inds)
noise_mask = latent_image.get("noise_mask", None)
callback = _sampling_callback(sampling_model, steps, preview_context)
samples = comfy_sample.sample_custom(
sampling_model,
noise,
cfg,
sampler,
sigmas,
positive,
negative,
latent_samples,
samples = sample_with_optional_negative(
comfy_sample=comfy_sample,
model=sampling_model,
noise=noise,
cfg=cfg,
sampler=sampler,
sigmas=sigmas,
positive=positive,
negative=negative,
latent_image=latent_samples,
noise_mask=noise_mask,
callback=callback,
disable_pbar=not comfy_utils.PROGRESS_BAR_ENABLED,
+5
View File
@@ -0,0 +1,5 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Provide model-family NegPiP runtime adapters."""
+108
View File
@@ -0,0 +1,108 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Apply PPM-compatible value-mask NegPiP behavior to Anima."""
# NegPiP behavior is adapted from ComfyUI-ppm and its credited predecessors.
# See third_party/manifest.toml and third_party/NOTICE.md.
from __future__ import annotations
from collections.abc import Callable
from typing import Any
import torch
from comfy import conds
WRAPPER_KEY = "ppm_negpip_anima"
CONDITION_MASK_KEY = "c_ppm_negpip_mask"
TRANSFORMER_MASK_KEY = "ppm_negpip_mask"
def anima_extra_conds_negpip_wrapper(
previous_extra_conds: Callable[..., dict[str, object]],
) -> Callable[..., dict[str, object]]:
"""Convert signed T5 weights into a model condition while preserving magnitude."""
def wrapped_extra_conds(**kwargs: object) -> dict[str, object]:
"""Publish a sequence-aligned value multiplier for one conditioning."""
weights = kwargs.get("t5xxl_weights")
multiplier: torch.Tensor | None = None
if weights is not None:
if not isinstance(weights, torch.Tensor):
raise TypeError("Anima NegPiP T5 weights must be a tensor.")
magnitude = weights.abs()
multiplier = (
torch.where(
weights < 0.0,
weights.new_tensor(-1.0),
weights.new_tensor(1.0),
)
.unsqueeze(0)
.unsqueeze(-1)
)
if multiplier.shape[1] < 512:
multiplier = torch.nn.functional.pad(
multiplier,
(0, 0, 0, 512 - multiplier.shape[1]),
value=1.0,
)
kwargs["t5xxl_weights"] = magnitude
output = previous_extra_conds(**kwargs)
if not isinstance(output, dict):
raise TypeError("Anima extra conditions must be a dictionary.")
if multiplier is not None:
output[CONDITION_MASK_KEY] = conds.CONDRegular(multiplier)
return output
return wrapped_extra_conds
def anima_diffusion_negpip_wrapper(
executor: Callable[..., object],
*args: object,
**kwargs: object,
) -> object:
"""Move the processed Anima multiplier into isolated transformer options."""
if len(args) < 3 or not isinstance(args[2], torch.Tensor):
raise TypeError("Anima NegPiP wrapper requires tensor conditioning context.")
context = args[2]
transformer_options = kwargs.get("transformer_options", {})
if not isinstance(transformer_options, dict):
raise TypeError("Anima transformer options must be a dictionary.")
prepared = transformer_options.copy()
multiplier = kwargs.get(CONDITION_MASK_KEY)
if multiplier is not None:
if not isinstance(multiplier, torch.Tensor):
raise TypeError("Anima NegPiP multiplier must be a tensor.")
prepared[TRANSFORMER_MASK_KEY] = multiplier.to(context)
kwargs["transformer_options"] = prepared
return executor(*args, **kwargs)
def anima_attn2_negpip(
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
pe: torch.Tensor | None = None,
attn_mask: torch.Tensor | None = None,
extra_options: dict[str, Any] | None = None,
) -> dict[str, torch.Tensor | None]:
"""Apply the signed multiplier only to Anima cross-attention values."""
multiplier = (
None if extra_options is None else extra_options.get(TRANSFORMER_MASK_KEY)
)
if multiplier is not None and not isinstance(multiplier, torch.Tensor):
raise TypeError("Anima NegPiP attention multiplier must be a tensor.")
return {
"q": query,
"k": key,
"v": value if multiplier is None else value * multiplier,
"pe": pe,
"attn_mask": attn_mask,
}
+291
View File
@@ -0,0 +1,291 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Adapt NegPiP value masking to Krea 2's layered Qwen conditioning."""
# NegPiP behavior is adapted from ComfyUI-ppm and its credited predecessors.
# See third_party/manifest.toml and third_party/NOTICE.md.
from __future__ import annotations
from collections.abc import Callable, Sequence
from typing import Any
import torch
from comfy import conds
CLIP_MARKER = "simple_syrup_negpip"
WRAPPER_KEY = "simple_syrup.negpip.krea2"
ENCODER_MASK_KEY = "simple_syrup_negpip_mask"
CONDITION_MASK_KEY = "c_simple_syrup_negpip_mask"
TRANSFORMER_MASK_KEY = "simple_syrup_negpip_mask"
KREA_TOKEN_KEY = "qwen3vl_4b"
IM_START_TOKEN = 151644
USER_TOKEN = 872
NEWLINE_TOKEN = 198
IMAGE_PAD_TOKEN = 151655
class Krea2NegpipTokenizer:
"""Preserve Krea templates while enabling Comfy prompt-weight tokenization."""
def __init__(self, source: object) -> None:
"""Retain one cloned CLIP's shared source tokenizer without mutating it."""
self._source = source
def __getattr__(self, name: str) -> object:
"""Delegate tokenizer metadata and helpers to the installed Krea tokenizer."""
return getattr(self._source, name)
def tokenize_with_weights(
self,
text: str,
return_word_ids: bool = False,
llama_template: str | None = None,
images: Sequence[torch.Tensor] = (),
prevent_empty_text: bool = False,
thinking: bool = True,
**kwargs: object,
) -> dict[str, list[list[tuple[object, ...]]]]:
"""Tokenize the normal Krea template while retaining parsed scalar weights."""
image = kwargs.pop("image", None)
if image is not None and not images:
if not isinstance(image, torch.Tensor):
raise TypeError("Krea tokenizer image input must be a tensor.")
images = tuple(image[index : index + 1] for index in range(image.shape[0]))
skip_template = bool(kwargs.pop("skip_template", False)) or text.startswith(
"<|im_start|>"
)
kwargs.pop("disable_weights", None)
if prevent_empty_text and text == "":
text = " "
if skip_template:
prepared_text = text
else:
template = llama_template
if template is None:
template_name = (
"llama_template" if not images else "llama_template_images"
)
template = getattr(self._source, template_name)
if not isinstance(template, str):
raise TypeError("Krea tokenizer template must be text.")
if len(images) > 1:
vision_block = "<|vision_start|><|image_pad|><|vision_end|>"
template = template.replace(
vision_block,
vision_block * len(images),
1,
)
prepared_text = template.format(text)
if not thinking:
prepared_text += "<think>\n\n</think>\n\n"
inner = getattr(self._source, KREA_TOKEN_KEY)
tokens = inner.tokenize_with_weights(
prepared_text,
return_word_ids=return_word_ids,
disable_weights=False,
**kwargs,
)
embedded_count = 0
for section in tokens:
for index, pair in enumerate(section):
token = pair[0]
if (
isinstance(token, (int, float))
and token == IMAGE_PAD_TOKEN
and embedded_count < len(images)
):
section[index] = (
{
"type": "image",
"data": images[embedded_count],
"original_type": "image",
},
*pair[1:],
)
embedded_count += 1
return {KREA_TOKEN_KEY: tokens}
def encode_krea2_token_weights_negpip(
original: Callable[..., tuple[object, ...]],
token_weight_pairs: dict[str, list[list[tuple[object, ...]]]],
template_end: int = -1,
) -> tuple[object, ...]:
"""Encode absolute Krea magnitudes and publish a post-template sign mask."""
sections = token_weight_pairs.get(KREA_TOKEN_KEY)
if not isinstance(sections, list) or len(sections) != 1:
raise ValueError("Krea NegPiP requires exactly one Qwen token section.")
source_section = sections[0]
absolute_section = [
(pair[0], abs(_token_weight(pair)), *pair[2:]) for pair in source_section
]
absolute_tokens = dict(token_weight_pairs)
absolute_tokens[KREA_TOKEN_KEY] = [absolute_section]
encoded = original(absolute_tokens, template_end=template_end)
if len(encoded) < 3 or not isinstance(encoded[0], torch.Tensor):
raise TypeError("Krea NegPiP encoder must return tensor conditioning metadata.")
extra = encoded[2]
if not isinstance(extra, dict):
raise TypeError("Krea NegPiP encoder metadata must be a dictionary.")
cut = _template_end(source_section) if template_end == -1 else template_end
signs = [
-1.0 if _token_weight(pair) < 0.0 else 1.0 for pair in source_section[cut:]
]
sequence_length = int(encoded[0].shape[1])
if len(signs) != sequence_length:
raise ValueError(
"Krea NegPiP sign mask does not match post-template conditioning: "
f"{len(signs)} signs for {sequence_length} tokens."
)
prepared_extra = dict(extra)
prepared_extra[ENCODER_MASK_KEY] = torch.tensor(signs).reshape(1, -1, 1)
return encoded[0], encoded[1], prepared_extra
def krea2_extra_conds_negpip_wrapper(
previous_extra_conds: Callable[..., dict[str, object]],
) -> Callable[..., dict[str, object]]:
"""Publish the Krea token-sign mask as a processed model condition."""
def wrapped_extra_conds(**kwargs: object) -> dict[str, object]:
"""Attach a validated sequence multiplier without altering other conditions."""
output = previous_extra_conds(**kwargs)
if not isinstance(output, dict):
raise TypeError("Krea extra conditions must be a dictionary.")
multiplier = kwargs.get(ENCODER_MASK_KEY)
if multiplier is not None:
if not isinstance(multiplier, torch.Tensor):
raise TypeError("Krea NegPiP sign mask must be a tensor.")
if (
multiplier.ndim != 3
or multiplier.shape[0] != 1
or multiplier.shape[2] != 1
):
raise ValueError(
"Krea NegPiP sign mask must have shape (1, sequence, 1)."
)
output[CONDITION_MASK_KEY] = conds.CONDRegular(multiplier)
return output
return wrapped_extra_conds
def krea2_diffusion_negpip_wrapper(
executor: Callable[..., object],
*args: object,
**kwargs: object,
) -> object:
"""Move a processed Krea sign mask into call-local transformer options."""
positional_options = args[5] if len(args) > 5 else None
transformer_options = (
positional_options
if positional_options is not None
else kwargs.get("transformer_options", {})
)
if not isinstance(transformer_options, dict):
raise TypeError("Krea transformer options must be a dictionary.")
prepared = transformer_options.copy()
multiplier = kwargs.get(CONDITION_MASK_KEY)
if multiplier is not None:
if not isinstance(multiplier, torch.Tensor):
raise TypeError("Krea NegPiP processed mask must be a tensor.")
prepared[TRANSFORMER_MASK_KEY] = multiplier
if len(args) > 5:
prepared_args = list(args)
prepared_args[5] = prepared
return executor(*prepared_args, **kwargs)
kwargs["transformer_options"] = prepared
return executor(*args, **kwargs)
def krea2_attn1_negpip(
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
pe: torch.Tensor | None = None,
attn_mask: torch.Tensor | None = None,
extra_options: dict[str, Any] | None = None,
) -> dict[str, torch.Tensor | None]:
"""Apply negative signs only to Krea text values in the joint token stream."""
options = {} if extra_options is None else extra_options
multiplier = options.get(TRANSFORMER_MASK_KEY)
if multiplier is None:
return {"q": query, "k": key, "v": value, "pe": pe, "attn_mask": attn_mask}
if not isinstance(multiplier, torch.Tensor):
raise TypeError("Krea NegPiP attention mask must be a tensor.")
image_slice = options.get("img_slice")
if (
not isinstance(image_slice, (list, tuple))
or len(image_slice) != 2
or any(
isinstance(item, bool) or not isinstance(item, int) for item in image_slice
)
):
raise ValueError("Krea NegPiP requires the model's text/image token boundary.")
text_length = image_slice[0]
if text_length != multiplier.shape[1] or value.shape[2] < text_length:
raise ValueError(
"Krea NegPiP mask does not match the joint attention sequence."
)
if multiplier.shape[0] not in {1, value.shape[0]} or multiplier.shape[2] != 1:
raise ValueError("Krea NegPiP mask has an incompatible batch or channel shape.")
text_multiplier = multiplier.to(device=value.device, dtype=value.dtype).unsqueeze(1)
prepared_value = value.to(copy=True)
prepared_value[:, :, :text_length, :] *= text_multiplier
return {
"q": query,
"k": key,
"v": prepared_value,
"pe": pe,
"attn_mask": attn_mask,
}
def _token_weight(pair: tuple[object, ...]) -> float:
"""Return one finite scalar token weight from a tokenizer tuple."""
if (
len(pair) < 2
or isinstance(pair[1], bool)
or not isinstance(pair[1], (int, float))
):
raise TypeError("Krea token weights must be numeric.")
weight = float(pair[1])
if not torch.isfinite(torch.tensor(weight)):
raise ValueError("Krea token weights must be finite.")
return weight
def _template_end(section: list[tuple[object, ...]]) -> int:
"""Resolve the exact Krea system and user-opening prefix boundary."""
count = 0
template_end = -1
for index, pair in enumerate(section):
token = pair[0]
if (
not isinstance(token, torch.Tensor)
and token == IM_START_TOKEN
and count < 2
):
template_end = index
count += 1
if (
len(section) > template_end + 3
and section[template_end + 1][0] == USER_TOKEN
and section[template_end + 2][0] == NEWLINE_TOKEN
):
template_end += 3
return template_end
+123
View File
@@ -0,0 +1,123 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Apply PPM-compatible NegPiP encoding for standard cross-attention models."""
# NegPiP behavior is adapted from ComfyUI-ppm and its credited predecessors.
# See third_party/manifest.toml and third_party/NOTICE.md.
from __future__ import annotations
from typing import Any
import torch
from comfy import model_management
from comfy.sd1_clip import SDClipModel, gen_empty_tokens
def standard_attn2_negpip(
query: torch.Tensor,
key: torch.Tensor,
value: torch.Tensor,
extra_options: dict[str, Any],
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Select magnitude embeddings for keys and signed embeddings for values."""
del extra_options
return query, key[:, 0::2], value[:, 1::2]
def encode_token_weights_negpip(
encoder: SDClipModel,
token_weight_pairs: list[list[tuple[object, float]]],
) -> tuple[object, ...]:
"""Encode absolute prompt magnitude and interleave signed value embeddings."""
tokens_to_encode: list[list[object]] = []
maximum_length = 0
has_weights = False
for section in token_weight_pairs:
tokens = [pair[0] for pair in section]
maximum_length = max(len(tokens), maximum_length)
has_weights = has_weights or any(pair[1] != 1.0 for pair in section)
tokens_to_encode.append(tokens)
section_count = len(tokens_to_encode)
if has_weights or section_count == 0:
if hasattr(encoder, "gen_empty_tokens"):
empty_tokens = encoder.gen_empty_tokens(
encoder.special_tokens,
maximum_length,
)
else:
empty_tokens = gen_empty_tokens(encoder.special_tokens, maximum_length)
tokens_to_encode.append(empty_tokens)
encoded = encoder.encode(tokens_to_encode)
output_tensor, pooled = encoded[:2]
if not isinstance(output_tensor, torch.Tensor):
raise TypeError("NegPiP text encoder output must be a tensor.")
first_pooled = (
pooled[0:1].to(device=model_management.intermediate_device())
if isinstance(pooled, torch.Tensor)
else pooled
)
outputs: list[torch.Tensor] = []
for section_index in range(section_count):
key_embedding = output_tensor[section_index : section_index + 1].to(copy=True)
value_embedding = key_embedding.to(copy=True)
if has_weights:
empty_embedding = output_tensor[-1]
for batch_index in range(len(key_embedding)):
for token_index in range(len(key_embedding[batch_index])):
weight = token_weight_pairs[section_index][token_index][1]
if weight == 1.0:
continue
magnitude = abs(weight)
key_embedding[batch_index][token_index] = (
key_embedding[batch_index][token_index]
- empty_embedding[token_index]
) * magnitude + empty_embedding[token_index]
value_embedding[batch_index][token_index] = (
value_embedding[batch_index][token_index]
- empty_embedding[token_index]
) * magnitude + empty_embedding[token_index]
if weight < 0.0:
value_embedding[batch_index][token_index].neg_()
interleaved = torch.zeros_like(key_embedding).repeat(1, 2, 1)
interleaved[:, 0::2, :] = key_embedding
interleaved[:, 1::2, :] = value_embedding
outputs.append(interleaved)
if outputs:
result: tuple[object, ...] = (
torch.cat(outputs, dim=-2).to(
device=model_management.intermediate_device()
),
first_pooled,
)
else:
result = (
output_tensor[-1:].to(device=model_management.intermediate_device()),
first_pooled,
)
if len(encoded) <= 2:
return result
source_extra = encoded[2]
if not isinstance(source_extra, dict):
raise TypeError("NegPiP text encoder metadata must be a dictionary.")
extra: dict[str, object] = {}
for key, value in source_extra.items():
if key == "attention_mask" and isinstance(value, torch.Tensor):
value = (
value[:section_count]
.flatten()
.unsqueeze(dim=0)
.to(device=model_management.intermediate_device())
)
extra[str(key)] = value
return (*result, extra)
+40 -20
View File
@@ -22,21 +22,30 @@ _ANIMA_CONDITION_KEY = "c_ppm_negpip_mask"
_ANIMA_TRANSFORMER_KEY = "ppm_negpip_mask"
_EXTRA_CONDS_PATH = "extra_conds"
_ATTN2_PATCH_NAME = "attn2_patch"
_UNET_CALLBACK = (
"src.negpip.unet_negpip",
"sdxl_attn2_negpip",
_UNET_CALLBACKS = (
("src.negpip.unet_negpip", "sdxl_attn2_negpip"),
("simple_syrup.runtime.negpip.standard", "standard_attn2_negpip"),
)
_ANIMA_CALLBACK = (
"src.negpip.anima_negpip",
"cosmos_attn2_negpip",
_ANIMA_CALLBACKS = (
("src.negpip.anima_negpip", "cosmos_attn2_negpip"),
("simple_syrup.runtime.negpip.anima", "anima_attn2_negpip"),
)
_ANIMA_WRAPPER = (
"src.negpip.anima_negpip",
"cosmos_diffusion_negpip_wrapper",
_ANIMA_WRAPPERS = (
("src.negpip.anima_negpip", "cosmos_diffusion_negpip_wrapper"),
(
"simple_syrup.runtime.negpip.anima",
"anima_diffusion_negpip_wrapper",
),
)
_ANIMA_EXTRA_CONDS = (
"src.negpip.anima_negpip",
"anima_extra_conds_negpip_wrapper.<locals>._anima_extra_conds_negpip_wrapper",
_ANIMA_EXTRA_CONDS_CALLBACKS = (
(
"src.negpip.anima_negpip",
"anima_extra_conds_negpip_wrapper.<locals>._anima_extra_conds_negpip_wrapper",
),
(
"simple_syrup.runtime.negpip.anima",
"anima_extra_conds_negpip_wrapper.<locals>.wrapped_extra_conds",
),
)
@@ -135,10 +144,12 @@ class PpmNegpipInteropValidator:
extra_conds = object_patches.get(_EXTRA_CONDS_PATH)
recognized_surface = any(
(
any(_is_identity(item, *_UNET_CALLBACK) for item in attention),
any(_is_identity(item, *_ANIMA_CALLBACK) for item in attention),
any(_matches_any_identity(item, _UNET_CALLBACKS) for item in attention),
any(
_matches_any_identity(item, _ANIMA_CALLBACKS) for item in attention
),
bool(anima_wrappers),
_is_identity(extra_conds, *_ANIMA_EXTRA_CONDS),
_matches_any_identity(extra_conds, _ANIMA_EXTRA_CONDS_CALLBACKS),
)
)
if not marker:
@@ -173,9 +184,9 @@ class PpmNegpipInteropValidator:
if (
len(attention) != 1
or not _is_identity(attention[0], *_UNET_CALLBACK)
or not _matches_any_identity(attention[0], _UNET_CALLBACKS)
or anima_wrappers
or _is_identity(extra_conds, *_ANIMA_EXTRA_CONDS)
or _matches_any_identity(extra_conds, _ANIMA_EXTRA_CONDS_CALLBACKS)
):
raise ValueError(
"Standard UNet NegPiP requires exactly its PPM split-K/V attention "
@@ -197,10 +208,10 @@ class PpmNegpipInteropValidator:
if (
len(attention) != 1
or not _is_identity(attention[0], *_ANIMA_CALLBACK)
or not _matches_any_identity(attention[0], _ANIMA_CALLBACKS)
or len(anima_wrappers) != 1
or not _is_identity(anima_wrappers[0], *_ANIMA_WRAPPER)
or not _is_identity(extra_conds, *_ANIMA_EXTRA_CONDS)
or not _matches_any_identity(anima_wrappers[0], _ANIMA_WRAPPERS)
or not _matches_any_identity(extra_conds, _ANIMA_EXTRA_CONDS_CALLBACKS)
):
raise ValueError(
"Anima NegPiP requires exactly its PPM attention patch, keyed "
@@ -230,4 +241,13 @@ def _is_identity(
)
def _matches_any_identity(
value: object,
identities: tuple[tuple[str, str], ...],
) -> bool:
"""Match a callable against either the installed PPM or owned equivalent."""
return any(_is_identity(value, *identity) for identity in identities)
PPM_NEGPIP_INTEROP_VALIDATOR = PpmNegpipInteropValidator()
@@ -86,6 +86,28 @@ class PromptControlGraphAdapter:
self.merge_expand(expand, negative.expand, "negative LoRA scheduling")
return negative.args[0], negative.args[1]
def apply_automatic_negpip(
self,
*,
model: Any,
clip: Any,
expand: dict[str, dict[str, Any]],
) -> tuple[Any, Any]:
"""Insert the runtime family check after a negative prompt-weight trigger."""
graph = self._graph_utils.GraphBuilder()
prepared = graph.node(
"SimpleSyrup.ApplyAutomaticNegpip",
model=model,
clip=clip,
)
self.merge_expand(
expand,
cast(dict[str, dict[str, Any]], graph.finalize()),
"automatic NegPiP preparation",
)
return prepared.out(0), prepared.out(1)
def encode_segment(
self,
*,
+27
View File
@@ -0,0 +1,27 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Declare shared checksum-pinned Qwen model artifacts."""
from __future__ import annotations
from .auto_model_artifact import AutoModelArtifact
QWEN_IMAGE_VAE = AutoModelArtifact(
cache_id="qwen_image_vae",
filename="qwen_image_vae.safetensors",
folder_name="vae",
canonical_subfolder="qwen",
source_url=(
"https://huggingface.co/Comfy-Org/Krea-2/resolve/"
"e5ea8b4dd7f38f348b138eb0fe29f92c0e367e96/vae/"
"qwen_image_vae.safetensors"
),
source_repo="Comfy-Org/Krea-2",
description="Qwen Image VAE",
sha256="a70580f0213e67967ee9c95f05bb400e8fb08307e017a924bf3441223e023d1f",
file_size_bytes=253_806_246,
)
__all__ = ["QWEN_IMAGE_VAE"]
@@ -0,0 +1,72 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Adapt shared detail sampling to regional MultiDiffusion runtime calls."""
from __future__ import annotations
from typing import Any
import torch
from ..domain.regional_detailing import LatentRegion
from . import regional_multidiffusion_sampling
from .detail_previews import DetailPreviewContext
from .detail_sampling import DetailSampler, Latent
class RegionalDetailSampler:
"""Adapt shared detail sampling helpers to regional MultiDiffusion."""
def __init__(self, detail_sampler: DetailSampler | None = None) -> None:
"""Create the runtime adapter with injectable encode/decode behavior."""
self._detail_sampler = detail_sampler or DetailSampler()
def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent:
"""Encode pixels into a latent dictionary."""
return self._detail_sampler.encode(vae, pixels, tiled)
def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor:
"""Decode latent samples into pixels."""
return self._detail_sampler.decode(vae, latent, tiled)
def sample_regions(
self,
*,
model: Any,
seed: int,
steps: int,
cfg: float,
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: Latent,
regions: tuple[LatentRegion, ...],
denoise: float,
global_prompt_weight: float,
preview_context: DetailPreviewContext | None = None,
differential_diffusion: bool = False,
) -> Latent:
"""Sample one full latent with regional MultiDiffusion."""
return regional_multidiffusion_sampling.sample_regional_multidiffusion(
model=model,
seed=seed,
steps=steps,
cfg=cfg,
sampler_name=sampler_name,
scheduler=scheduler,
positive=positive,
negative=negative,
latent_image=latent_image,
regions=regions,
denoise=denoise,
global_prompt_weight=global_prompt_weight,
preview_context=preview_context,
differential_diffusion=differential_diffusion,
)

Some files were not shown because too many files have changed in this diff Show More