Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec49b685db | ||
|
|
2ae545d64d | ||
|
|
81b7a8fe63 | ||
|
|
2fc10b0c5e | ||
|
|
e7ff15d5a7 | ||
|
|
5bc3200832 | ||
|
|
c484e9d236 | ||
|
|
dc6a0723a5 | ||
|
|
6184b74809 | ||
|
|
9948cb3433 | ||
|
|
6cb9bbe868 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -1,3 +1,39 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+12
-10
@@ -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"
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema_version = 1
|
||||
debts = []
|
||||
@@ -0,0 +1 @@
|
||||
schema_version = 1
|
||||
@@ -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"
|
||||
@@ -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 = []
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
schema_version = 1
|
||||
debts = []
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "simple-syrup-comfyui",
|
||||
"version": "1.9.2",
|
||||
"version": "1.11.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "simple-syrup-comfyui",
|
||||
"version": "1.9.2",
|
||||
"version": "1.11.1",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "simple-syrup-comfyui",
|
||||
"version": "1.9.2",
|
||||
"version": "1.11.1",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "SimpleSyrup"
|
||||
description = "Workflow-focused ComfyUI extensions for image generation."
|
||||
version = "1.9.2"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__ = "1.9.2"
|
||||
__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,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:
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
+1
-1
@@ -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"
|
||||
+1
-1
@@ -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]]
|
||||
+3
-3
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from ..runtime.model_downloads import ComfyProgressReporter
|
||||
from ..runtime.ultralytics_loader import UltralyticsLoaderService
|
||||
from ..services.ultralytics_loader_service import UltralyticsLoaderService
|
||||
|
||||
|
||||
class LoadUltralyticsModel:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -70,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:
|
||||
@@ -82,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)),
|
||||
@@ -145,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):
|
||||
@@ -219,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):
|
||||
@@ -227,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):
|
||||
@@ -235,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):
|
||||
@@ -309,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",
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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()
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -20,7 +20,6 @@ from .anima_attention_coupling import anima_attention_coupling_mutations
|
||||
from .anima_attention_execution import AnimaRegionalAttentionExecution
|
||||
from .anima_composition import AnimaRegionalLoraComposition
|
||||
from .anima_execution_scope import AnimaRegionalLoraAdapterExecution
|
||||
from .anima_global_lora_overlap import ANIMA_GLOBAL_REGIONAL_LORA_OVERLAP_VALIDATOR
|
||||
from .anima_model_patcher_surface import ANIMA_MODEL_PATCHER_SURFACE_RESOLVER
|
||||
from .anima_plan_admission import ANIMA_REGIONAL_LORA_PLAN_ADMISSION_SERVICE
|
||||
from .execution_cache import ModelCloneLineage, RegionalLoraExecutionCache
|
||||
@@ -56,7 +55,6 @@ class FullContextAnimaAttentionBackend:
|
||||
if negpip is not None and not isinstance(negpip, PpmNegpipInterop):
|
||||
raise TypeError("Anima backend NegPiP state has an invalid type.")
|
||||
admitted = ANIMA_REGIONAL_LORA_PLAN_ADMISSION_SERVICE.admit(adaptation)
|
||||
ANIMA_GLOBAL_REGIONAL_LORA_OVERLAP_VALIDATOR.validate(model, admitted)
|
||||
template = build_regional_attention_template(
|
||||
processed_plan,
|
||||
latent_batch_size=latent_batch_size,
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Reject exact static-global and admitted-regional Anima LoRA overlap."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
|
||||
import torch
|
||||
from comfy.weight_adapter.lora import LoRAAdapter
|
||||
|
||||
from .anima_plan_admission import (
|
||||
AnimaRegionalLoraAdapterAdmission,
|
||||
AnimaRegionalLoraPlanAdmission,
|
||||
)
|
||||
from .standard_adapter import StandardLoraTarget
|
||||
|
||||
|
||||
class AnimaGlobalRegionalLoraOverlapError(ValueError):
|
||||
"""Report regional adapters already present in the static global MODEL."""
|
||||
|
||||
|
||||
class AnimaGlobalRegionalLoraOverlapValidator:
|
||||
"""Compare exact admitted regional A/B tensors with static global patches."""
|
||||
|
||||
def validate(
|
||||
self,
|
||||
model: object,
|
||||
admission: AnimaRegionalLoraPlanAdmission,
|
||||
) -> None:
|
||||
"""Reject every regional adapter whose complete content is global."""
|
||||
|
||||
if not isinstance(admission, AnimaRegionalLoraPlanAdmission):
|
||||
raise TypeError("Anima global LoRA overlap requires an admitted plan.")
|
||||
patches = getattr(model, "patches", None)
|
||||
if not isinstance(patches, Mapping):
|
||||
raise TypeError("Anima global LoRA overlap requires MODEL patches.")
|
||||
duplicates = tuple(
|
||||
adapter.adapter_plan.adapter_identity.value
|
||||
for adapter in admission.adapters
|
||||
if self._duplicates_global_content(patches, adapter)
|
||||
)
|
||||
unique_duplicates = tuple(dict.fromkeys(duplicates))
|
||||
if unique_duplicates:
|
||||
identities = ", ".join(repr(value) for value in unique_duplicates)
|
||||
raise AnimaGlobalRegionalLoraOverlapError(
|
||||
"Regional Anima LoRA content is already applied globally to the "
|
||||
f"input MODEL: {identities}. Remove either the global or regional "
|
||||
"application before sampling."
|
||||
)
|
||||
|
||||
def _duplicates_global_content(
|
||||
self,
|
||||
patches: Mapping[object, object],
|
||||
adapter: AnimaRegionalLoraAdapterAdmission,
|
||||
) -> bool:
|
||||
"""Return whether every admitted regional target has an exact global pair."""
|
||||
|
||||
targets = adapter.admission.targets
|
||||
return bool(targets) and all(
|
||||
self._target_matches(patches, target.adapter) for target in targets
|
||||
)
|
||||
|
||||
def _target_matches(
|
||||
self,
|
||||
patches: Mapping[object, object],
|
||||
regional: StandardLoraTarget,
|
||||
) -> bool:
|
||||
"""Match one regional target against nonzero comparable static patches."""
|
||||
|
||||
key = f"{regional.target}.weight"
|
||||
entries = patches.get(key, ())
|
||||
if entries == ():
|
||||
return False
|
||||
if not isinstance(entries, list):
|
||||
raise TypeError(f"MODEL patches[{key!r}] must be a list.")
|
||||
for index, entry in enumerate(entries):
|
||||
if not isinstance(entry, tuple) or len(entry) < 3:
|
||||
raise TypeError(
|
||||
f"MODEL patches[{key!r}][{index}] must be a Comfy patch tuple."
|
||||
)
|
||||
if _nonzero_strength(entry[0], key=key, index=index) and _matches_pair(
|
||||
entry[1],
|
||||
regional,
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _nonzero_strength(value: object, *, key: str, index: int) -> bool:
|
||||
"""Validate one installed static patch strength and report its activity."""
|
||||
|
||||
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||
raise TypeError(f"MODEL patches[{key!r}][{index}] strength must be numeric.")
|
||||
strength = float(value)
|
||||
if not math.isfinite(strength):
|
||||
raise ValueError(f"MODEL patches[{key!r}][{index}] strength must be finite.")
|
||||
return strength != 0.0
|
||||
|
||||
|
||||
def _matches_pair(value: object, regional: StandardLoraTarget) -> bool:
|
||||
"""Compare one installed standard LoRA patch without copies or transfers."""
|
||||
|
||||
if not isinstance(value, LoRAAdapter):
|
||||
return False
|
||||
weights = value.weights
|
||||
if not isinstance(weights, tuple) or len(weights) != 6:
|
||||
return False
|
||||
up, down, alpha, mid, dora_scale, reshape = weights
|
||||
if any(item is not None for item in (alpha, mid, dora_scale, reshape)):
|
||||
return False
|
||||
if not isinstance(down, torch.Tensor) or not isinstance(up, torch.Tensor):
|
||||
return False
|
||||
return _same_tensor(down, regional.down) and _same_tensor(up, regional.up)
|
||||
|
||||
|
||||
def _same_tensor(left: torch.Tensor, right: torch.Tensor) -> bool:
|
||||
"""Use an identity fast path before exact same-residency tensor equality."""
|
||||
|
||||
if left is right:
|
||||
return True
|
||||
if (
|
||||
left.shape != right.shape
|
||||
or left.dtype != right.dtype
|
||||
or left.device != right.device
|
||||
):
|
||||
return False
|
||||
return bool(torch.equal(left, right))
|
||||
|
||||
|
||||
ANIMA_GLOBAL_REGIONAL_LORA_OVERLAP_VALIDATOR = AnimaGlobalRegionalLoraOverlapValidator()
|
||||
@@ -0,0 +1,126 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Admit complete standard-UNet regional LoRA operation surfaces."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import comfy.model_patcher
|
||||
from torch import nn
|
||||
|
||||
from ..attention_coupling.family_admission import AttentionCouplingFamilyAdmission
|
||||
from ..regional_lora_plan_adapter import RegionalLoraPlanAdaptation
|
||||
from .comfy_adapter_resolver import COMFY_REGIONAL_ADAPTER_RESOLVER
|
||||
from .execution_cache import RegionalLoraExecutionCache
|
||||
from .resolved_operation_translator import COMFY_RESOLVED_OPERATION_TRANSLATOR
|
||||
from .standard_unet_target_capabilities import (
|
||||
STANDARD_UNET_TARGET_CAPABILITY_CLASSIFIER,
|
||||
)
|
||||
from .target_binder import REGIONAL_LORA_TARGET_BINDER
|
||||
from .target_binding import (
|
||||
BoundRegionalLoraSpatialCapability,
|
||||
RegionalLoraBindingResult,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StandardUnetOperationAdmission(AttentionCouplingFamilyAdmission):
|
||||
"""Retain target bindings and exact runtime consumer-role evidence."""
|
||||
|
||||
binding: RegionalLoraBindingResult | None
|
||||
module_roles: dict[str, BoundRegionalLoraSpatialCapability]
|
||||
cache: RegionalLoraExecutionCache | None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Require either an empty admission or a complete executable surface."""
|
||||
|
||||
AttentionCouplingFamilyAdmission.__post_init__(self)
|
||||
if not self.adaptation.plan.adapters:
|
||||
if self.binding is not None or self.module_roles or self.cache is not None:
|
||||
raise ValueError("Empty standard admission cannot retain operations.")
|
||||
return
|
||||
if (
|
||||
not isinstance(self.binding, RegionalLoraBindingResult)
|
||||
or not self.binding.admissible
|
||||
or not self.binding.entries
|
||||
):
|
||||
raise ValueError("Standard admission requires complete target binding.")
|
||||
if not self.module_roles or not isinstance(
|
||||
self.cache,
|
||||
RegionalLoraExecutionCache,
|
||||
):
|
||||
raise ValueError("Standard admission requires roles and execution cache.")
|
||||
|
||||
|
||||
class StandardUnetOperationPreparation:
|
||||
"""Resolve, translate, bind, and classify regional LoRA operations."""
|
||||
|
||||
def admit(
|
||||
self,
|
||||
model: object,
|
||||
adaptation: RegionalLoraPlanAdaptation,
|
||||
) -> StandardUnetOperationAdmission:
|
||||
"""Return complete immutable evidence before installing call-scoped work."""
|
||||
|
||||
if not isinstance(adaptation, RegionalLoraPlanAdaptation):
|
||||
raise TypeError("Standard UNet operation admission requires adaptation.")
|
||||
if not adaptation.plan.adapters:
|
||||
return StandardUnetOperationAdmission(adaptation, None, {}, None)
|
||||
if not isinstance(model, comfy.model_patcher.ModelPatcher):
|
||||
raise TypeError("Standard UNet operation admission requires a MODEL.")
|
||||
graph_root = model.model
|
||||
if not isinstance(graph_root, nn.Module):
|
||||
raise TypeError("Standard UNet MODEL graph must be an nn.Module.")
|
||||
capabilities = STANDARD_UNET_TARGET_CAPABILITY_CLASSIFIER.classify(graph_root)
|
||||
resolution = COMFY_REGIONAL_ADAPTER_RESOLVER.resolve(
|
||||
adaptation,
|
||||
model=model,
|
||||
)
|
||||
operations = COMFY_RESOLVED_OPERATION_TRANSLATOR.translate(resolution)
|
||||
binding = REGIONAL_LORA_TARGET_BINDER.bind(
|
||||
source=model,
|
||||
candidate=model,
|
||||
resolution=resolution,
|
||||
operations=operations,
|
||||
linear_spatial_capabilities=capabilities.linear_roles,
|
||||
)
|
||||
if not binding.admissible:
|
||||
messages = tuple(issue.message for issue in binding.issues)
|
||||
raise ValueError(
|
||||
f"Standard UNet regional LoRA target admission failed: {messages!r}."
|
||||
)
|
||||
unavailable = tuple(
|
||||
entry.descriptor.target.parameter_path
|
||||
for entry in binding.entries
|
||||
if entry.spatial_capability
|
||||
in (
|
||||
BoundRegionalLoraSpatialCapability.GLOBAL_ONLY,
|
||||
BoundRegionalLoraSpatialCapability.UNSUPPORTED,
|
||||
)
|
||||
)
|
||||
if unavailable:
|
||||
raise ValueError(
|
||||
"Standard UNet regional LoRA targets lack executable consumer roles: "
|
||||
f"{unavailable!r}."
|
||||
)
|
||||
module_roles: dict[str, BoundRegionalLoraSpatialCapability] = {}
|
||||
for entry in binding.entries:
|
||||
path = entry.descriptor.target.model_target
|
||||
role = entry.spatial_capability
|
||||
previous = module_roles.setdefault(path, role)
|
||||
if previous is not role:
|
||||
raise ValueError(
|
||||
f"Standard UNet operation {path!r} has conflicting roles."
|
||||
)
|
||||
return StandardUnetOperationAdmission(
|
||||
adaptation,
|
||||
binding,
|
||||
module_roles,
|
||||
RegionalLoraExecutionCache(),
|
||||
)
|
||||
|
||||
|
||||
STANDARD_UNET_OPERATION_PREPARATION = StandardUnetOperationPreparation()
|
||||
@@ -0,0 +1,333 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Resolve spatial regional LoRA operations within one standard-UNet call."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
|
||||
from ...domain.regional_activation_geometry import (
|
||||
RegionalActivationGeometry,
|
||||
RegionalActivationLayout,
|
||||
RegionalTemporalOwnership,
|
||||
)
|
||||
from ...domain.regional_attention_batch import BatchedRegionalAttentionContexts
|
||||
from ...domain.regional_lora_plan import RegionalLoraPlan
|
||||
from ...domain.regional_mask_bank import RegionalMaskBank
|
||||
from ...domain.spatial_views import SpatialBatchLayout
|
||||
from ...masking.regional_activation_mask_projection import (
|
||||
REGIONAL_ACTIVATION_MASK_PROJECTOR,
|
||||
)
|
||||
from ...masking.regional_mask_projection import (
|
||||
RegionalMaskForm,
|
||||
RegionalMaskProjectionMode,
|
||||
)
|
||||
from ..attention_coupling.unet_attn2_execution import UnetAttn2Execution
|
||||
from ..spatial_model_arguments import (
|
||||
SIMPLE_SYRUP_TRANSFORMER_NAMESPACE,
|
||||
SPATIAL_BATCH_LAYOUT_KEY,
|
||||
)
|
||||
from .activation_batch_alignment import (
|
||||
REGIONAL_ACTIVATION_BATCH_ALIGNMENT_RESOLVER,
|
||||
)
|
||||
from .convolution_execution_plan import RegionalConvolutionExecutionPlan
|
||||
from .convolution_rank_geometry import REGIONAL_CONVOLUTION_RANK_GEOMETRY_RESOLVER
|
||||
from .linear_execution_plan import RegionalLinearExecutionPlan
|
||||
from .operation_call_scope import RegionalOperationCallScope
|
||||
from .operation_invocation import (
|
||||
REGIONAL_OPERATION_INVOCATION_CONTEXT,
|
||||
RegionalOperationExecutionPlan,
|
||||
RegionalOperationInvocation,
|
||||
RegionalOperationInvocationContext,
|
||||
)
|
||||
from .operation_mask_resolution import REGIONAL_OPERATION_MASK_RESOLVER
|
||||
from .standard_unet_lora_schedule import StandardUnetLoraSchedule
|
||||
from .standard_unet_packed_operation_masks import (
|
||||
STANDARD_UNET_PACKED_OPERATION_MASK_RESOLVER,
|
||||
)
|
||||
from .target_binding import BoundRegionalLoraSpatialCapability
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _ActiveStandardUnetOperationCall:
|
||||
"""Retain call authorities and optional compact attn2 execution state."""
|
||||
|
||||
contexts: BatchedRegionalAttentionContexts
|
||||
transformer_options: dict[str, object]
|
||||
schedule_strengths: tuple[float, ...]
|
||||
packed_execution: UnetAttn2Execution | None = None
|
||||
|
||||
|
||||
class StandardUnetRegionalOperationSession:
|
||||
"""Own one shared UNet trajectory with spatial regional LoRA deltas."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plan: RegionalLoraPlan,
|
||||
mask_bank: RegionalMaskBank,
|
||||
module_roles: Mapping[str, BoundRegionalLoraSpatialCapability],
|
||||
call_scope: RegionalOperationCallScope,
|
||||
*,
|
||||
invocation_context: RegionalOperationInvocationContext = (
|
||||
REGIONAL_OPERATION_INVOCATION_CONTEXT
|
||||
),
|
||||
) -> None:
|
||||
"""Retain immutable composition, mask, role, and operation authorities."""
|
||||
|
||||
if not isinstance(plan, RegionalLoraPlan) or not plan.adapters:
|
||||
raise ValueError("Standard UNet operation session requires adapters.")
|
||||
if not isinstance(mask_bank, RegionalMaskBank):
|
||||
raise TypeError("Standard UNet operation session requires a mask bank.")
|
||||
if not isinstance(module_roles, Mapping) or not module_roles:
|
||||
raise ValueError("Standard UNet operation session requires module roles.")
|
||||
roles = dict(module_roles)
|
||||
if any(not isinstance(path, str) or not path for path in roles):
|
||||
raise ValueError("Standard UNet operation paths must be nonempty.")
|
||||
supported = (
|
||||
BoundRegionalLoraSpatialCapability.SPATIAL_TOKENS,
|
||||
BoundRegionalLoraSpatialCapability.PACKED_IMAGE_TOKENS,
|
||||
BoundRegionalLoraSpatialCapability.PACKED_CONTEXT_TOKENS,
|
||||
BoundRegionalLoraSpatialCapability.DIRECT,
|
||||
)
|
||||
if any(role not in supported for role in roles.values()):
|
||||
raise ValueError("Standard UNet operation role is unsupported.")
|
||||
if not isinstance(call_scope, RegionalOperationCallScope):
|
||||
raise TypeError("Standard UNet operation session requires a call scope.")
|
||||
if not isinstance(invocation_context, RegionalOperationInvocationContext):
|
||||
raise TypeError(
|
||||
"Standard UNet operation session requires invocation context."
|
||||
)
|
||||
self._mask_bank = mask_bank
|
||||
self._module_roles = roles
|
||||
self._call_scope = call_scope
|
||||
self._invocation_context = invocation_context
|
||||
self._schedule = StandardUnetLoraSchedule(plan)
|
||||
self._active: ContextVar[_ActiveStandardUnetOperationCall | None] = ContextVar(
|
||||
"simple_syrup_standard_unet_regional_operation_call",
|
||||
default=None,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def activate(
|
||||
self,
|
||||
contexts: BatchedRegionalAttentionContexts,
|
||||
transformer_options: dict[str, object],
|
||||
) -> Iterator[None]:
|
||||
"""Publish operation masks and install wrappers for one model call."""
|
||||
|
||||
if not isinstance(contexts, BatchedRegionalAttentionContexts):
|
||||
raise TypeError("Standard UNet operation call requires contexts.")
|
||||
if not isinstance(transformer_options, dict):
|
||||
raise TypeError("Standard UNet operation call requires options.")
|
||||
active = _ActiveStandardUnetOperationCall(
|
||||
contexts,
|
||||
transformer_options,
|
||||
self._schedule.resolve(transformer_options),
|
||||
)
|
||||
token = self._active.set(active)
|
||||
try:
|
||||
with (
|
||||
self._invocation_context.activate(self),
|
||||
self._call_scope.activate(),
|
||||
):
|
||||
yield
|
||||
finally:
|
||||
active.packed_execution = None
|
||||
self._active.reset(token)
|
||||
|
||||
def begin_packed(self, execution: UnetAttn2Execution) -> None:
|
||||
"""Publish compact attn2 execution until its paired output callback."""
|
||||
|
||||
active = self._require_active()
|
||||
if active.packed_execution is not None:
|
||||
raise ValueError("Standard UNet operation call already has packed state.")
|
||||
if not isinstance(execution, UnetAttn2Execution):
|
||||
raise TypeError("Standard UNet packed state requires attn2 execution.")
|
||||
active.packed_execution = execution
|
||||
|
||||
def end_packed(self, execution: UnetAttn2Execution) -> None:
|
||||
"""Clear only the compact execution opened by the input callback."""
|
||||
|
||||
active = self._require_active()
|
||||
if active.packed_execution is not execution:
|
||||
raise ValueError("Standard UNet packed output does not match input state.")
|
||||
active.packed_execution = None
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
module_path: str,
|
||||
plan: RegionalOperationExecutionPlan,
|
||||
inputs: torch.Tensor,
|
||||
) -> RegionalOperationInvocation | None:
|
||||
"""Resolve one installed operation from its declared consumer role."""
|
||||
|
||||
active = self._require_active()
|
||||
role = self._module_roles.get(module_path)
|
||||
if role is None:
|
||||
return None
|
||||
strengths = tuple(
|
||||
active.schedule_strengths[use.composition_index] for use in plan.uses
|
||||
)
|
||||
if role is BoundRegionalLoraSpatialCapability.PACKED_IMAGE_TOKENS:
|
||||
execution = self._require_packed(active)
|
||||
if not isinstance(plan, RegionalLinearExecutionPlan):
|
||||
raise TypeError("Packed image role requires a Linear plan.")
|
||||
masks = STANDARD_UNET_PACKED_OPERATION_MASK_RESOLVER.resolve_image_tokens(
|
||||
execution,
|
||||
uses=plan.uses,
|
||||
inputs=inputs,
|
||||
)
|
||||
elif role is BoundRegionalLoraSpatialCapability.PACKED_CONTEXT_TOKENS:
|
||||
execution = self._require_packed(active)
|
||||
if not isinstance(plan, RegionalLinearExecutionPlan):
|
||||
raise TypeError("Packed context role requires a Linear plan.")
|
||||
masks = STANDARD_UNET_PACKED_OPERATION_MASK_RESOLVER.resolve_context_tokens(
|
||||
execution,
|
||||
uses=plan.uses,
|
||||
inputs=inputs,
|
||||
)
|
||||
else:
|
||||
if active.packed_execution is not None:
|
||||
raise ValueError("Ordinary regional operation ran inside packed attn2.")
|
||||
geometry = self._ordinary_geometry(
|
||||
role,
|
||||
plan=plan,
|
||||
inputs=inputs,
|
||||
active=active,
|
||||
)
|
||||
spatial = REGIONAL_ACTIVATION_MASK_PROJECTOR.project(
|
||||
bank=self._mask_bank,
|
||||
geometry=geometry,
|
||||
form=RegionalMaskForm.CONDITIONING,
|
||||
mode=RegionalMaskProjectionMode.CONTINUOUS_COVERAGE,
|
||||
device=inputs.device,
|
||||
dtype=inputs.dtype,
|
||||
)
|
||||
masks = REGIONAL_OPERATION_MASK_RESOLVER.resolve(
|
||||
spatial,
|
||||
contexts=active.contexts,
|
||||
uses=plan.uses,
|
||||
)
|
||||
return RegionalOperationInvocation(masks, strengths)
|
||||
|
||||
def clear(self, model: object, unpatch_all: bool) -> None:
|
||||
"""Release retained sampling schedule state on model detach."""
|
||||
|
||||
del model, unpatch_all
|
||||
self._schedule.clear()
|
||||
|
||||
def _ordinary_geometry(
|
||||
self,
|
||||
role: BoundRegionalLoraSpatialCapability,
|
||||
*,
|
||||
plan: RegionalOperationExecutionPlan,
|
||||
inputs: torch.Tensor,
|
||||
active: _ActiveStandardUnetOperationCall,
|
||||
) -> RegionalActivationGeometry:
|
||||
"""Resolve exact ordinary token or convolution activation geometry."""
|
||||
|
||||
layout = _spatial_layout(active.transformer_options)
|
||||
alignment = REGIONAL_ACTIVATION_BATCH_ALIGNMENT_RESOLVER.resolve(
|
||||
active.contexts,
|
||||
spatial_layout=layout,
|
||||
)
|
||||
if role is BoundRegionalLoraSpatialCapability.SPATIAL_TOKENS:
|
||||
if not isinstance(plan, RegionalLinearExecutionPlan) or inputs.ndim != 3:
|
||||
raise ValueError("Spatial-token role requires B/S/C Linear inputs.")
|
||||
activation_shape = _activation_shape(active.transformer_options)
|
||||
if int(inputs.shape[0]) != activation_shape[0] or int(inputs.shape[1]) != (
|
||||
activation_shape[2] * activation_shape[3]
|
||||
):
|
||||
raise ValueError("Spatial-token inputs must match live activation H/W.")
|
||||
return RegionalActivationGeometry(
|
||||
RegionalActivationLayout.CONSUMER_SPATIALIZED,
|
||||
tuple(inputs.shape),
|
||||
2,
|
||||
activation_shape[2],
|
||||
activation_shape[3],
|
||||
alignment,
|
||||
)
|
||||
if role is not BoundRegionalLoraSpatialCapability.DIRECT or not isinstance(
|
||||
plan,
|
||||
RegionalConvolutionExecutionPlan,
|
||||
):
|
||||
raise ValueError("Standard UNet ordinary operation role is inconsistent.")
|
||||
use = plan.uses[0]
|
||||
spatial = REGIONAL_CONVOLUTION_RANK_GEOMETRY_RESOLVER.resolve(
|
||||
tuple(int(value) for value in inputs.shape[2:]),
|
||||
use,
|
||||
)
|
||||
rank_channels = int(use.preparation.down.shape[0]) * use.parameters.groups
|
||||
layouts = {
|
||||
1: RegionalActivationLayout.DIRECT_CONVOLUTION_1D,
|
||||
2: RegionalActivationLayout.DIRECT_CONVOLUTION_2D,
|
||||
3: RegionalActivationLayout.DIRECT_CONVOLUTION_3D,
|
||||
}
|
||||
return RegionalActivationGeometry(
|
||||
layouts[use.parameters.dimension],
|
||||
(int(inputs.shape[0]), rank_channels, *spatial),
|
||||
1,
|
||||
1 if use.parameters.dimension == 1 else spatial[-2],
|
||||
spatial[-1],
|
||||
alignment,
|
||||
temporal_axis=2 if use.parameters.dimension == 3 else None,
|
||||
temporal_ownership=(
|
||||
RegionalTemporalOwnership.REPEAT_SPATIAL_MASK
|
||||
if use.parameters.dimension == 3
|
||||
else RegionalTemporalOwnership.NONE
|
||||
),
|
||||
)
|
||||
|
||||
def _require_active(self) -> _ActiveStandardUnetOperationCall:
|
||||
"""Return the current call or reject execution outside its owner."""
|
||||
|
||||
active = self._active.get()
|
||||
if active is None:
|
||||
raise RuntimeError("Standard UNet regional operation ran outside a call.")
|
||||
return active
|
||||
|
||||
@staticmethod
|
||||
def _require_packed(
|
||||
active: _ActiveStandardUnetOperationCall,
|
||||
) -> UnetAttn2Execution:
|
||||
"""Return the compact attn2 authority for the current projection."""
|
||||
|
||||
if active.packed_execution is None:
|
||||
raise RuntimeError("Packed regional operation ran outside attn2 scope.")
|
||||
return active.packed_execution
|
||||
|
||||
|
||||
def _activation_shape(options: dict[str, object]) -> tuple[int, int, int, int]:
|
||||
"""Narrow Comfy's live spatial-transformer BCHW metadata."""
|
||||
|
||||
value = options.get("activations_shape")
|
||||
if not isinstance(value, list | tuple) or len(value) != 4:
|
||||
raise TypeError("Standard UNet activations_shape must be a BCHW sequence.")
|
||||
shape = tuple(value)
|
||||
if any(
|
||||
isinstance(item, bool) or not isinstance(item, int) or item < 1
|
||||
for item in shape
|
||||
):
|
||||
raise ValueError("Standard UNet activation dimensions must be positive.")
|
||||
return shape[0], shape[1], shape[2], shape[3]
|
||||
|
||||
|
||||
def _spatial_layout(options: dict[str, object]) -> SpatialBatchLayout | None:
|
||||
"""Return the optional authoritative full, tiled, or Contextual layout."""
|
||||
|
||||
namespace = options.get(SIMPLE_SYRUP_TRANSFORMER_NAMESPACE)
|
||||
if namespace is None:
|
||||
return None
|
||||
if not isinstance(namespace, dict):
|
||||
raise TypeError("Standard UNet SimpleSyrup namespace must be a dictionary.")
|
||||
layout = namespace.get(SPATIAL_BATCH_LAYOUT_KEY)
|
||||
if layout is not None and not isinstance(layout, SpatialBatchLayout):
|
||||
raise TypeError("Standard UNet spatial layout has an invalid type.")
|
||||
return layout
|
||||
@@ -0,0 +1,147 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Resolve regional operation masks for compact standard-UNet attn2 rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import torch
|
||||
|
||||
from ...domain.regional_activation_geometry import (
|
||||
RegionalActivationBatchAlignment,
|
||||
RegionalActivationGeometry,
|
||||
RegionalActivationLayout,
|
||||
)
|
||||
from ..attention_coupling.unet_attn2_execution import UnetAttn2Execution
|
||||
from .operation_mask_resolution import (
|
||||
REGIONAL_OPERATION_BRANCH_GATE_RESOLVER,
|
||||
RegionalOperationMaskBatch,
|
||||
RegionalOperationMaskUse,
|
||||
)
|
||||
|
||||
|
||||
class StandardUnetPackedOperationMaskResolver:
|
||||
"""Map regional uses onto exact compact attn2 image or context rows."""
|
||||
|
||||
def resolve_image_tokens(
|
||||
self,
|
||||
execution: UnetAttn2Execution,
|
||||
*,
|
||||
uses: Sequence[RegionalOperationMaskUse],
|
||||
inputs: torch.Tensor,
|
||||
) -> RegionalOperationMaskBatch:
|
||||
"""Return query-grid masks for packed query and output projections."""
|
||||
|
||||
self._validate(execution, uses=uses, inputs=inputs)
|
||||
if int(inputs.shape[1]) != execution.query_height * execution.query_width:
|
||||
raise ValueError("Packed image tokens must match attn2 query H/W.")
|
||||
use_masks = tuple(
|
||||
self._packed_use_mask(execution, use=use, inputs=inputs, spatial=True)
|
||||
for use in uses
|
||||
)
|
||||
geometry = RegionalActivationGeometry(
|
||||
RegionalActivationLayout.CONSUMER_SPATIALIZED,
|
||||
tuple(inputs.shape),
|
||||
2,
|
||||
execution.query_height,
|
||||
execution.query_width,
|
||||
RegionalActivationBatchAlignment(int(inputs.shape[0]), 1),
|
||||
)
|
||||
return RegionalOperationMaskBatch(
|
||||
torch.stack(use_masks),
|
||||
geometry,
|
||||
tuple(use.composition_index for use in uses),
|
||||
)
|
||||
|
||||
def resolve_context_tokens(
|
||||
self,
|
||||
execution: UnetAttn2Execution,
|
||||
*,
|
||||
uses: Sequence[RegionalOperationMaskUse],
|
||||
inputs: torch.Tensor,
|
||||
) -> RegionalOperationMaskBatch:
|
||||
"""Return branch gates broadcast over untouched context tokens."""
|
||||
|
||||
self._validate(execution, uses=uses, inputs=inputs)
|
||||
use_masks = tuple(
|
||||
self._packed_use_mask(execution, use=use, inputs=inputs, spatial=False)
|
||||
for use in uses
|
||||
)
|
||||
geometry = RegionalActivationGeometry(
|
||||
RegionalActivationLayout.BRANCH_TOKENS,
|
||||
tuple(inputs.shape),
|
||||
2,
|
||||
1,
|
||||
int(inputs.shape[1]),
|
||||
RegionalActivationBatchAlignment(int(inputs.shape[0]), 1),
|
||||
)
|
||||
return RegionalOperationMaskBatch(
|
||||
torch.stack(use_masks),
|
||||
geometry,
|
||||
tuple(use.composition_index for use in uses),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate(
|
||||
execution: object,
|
||||
*,
|
||||
uses: Sequence[RegionalOperationMaskUse],
|
||||
inputs: object,
|
||||
) -> None:
|
||||
"""Require one exact packed B/S/C activation and ordered use sequence."""
|
||||
|
||||
if not isinstance(execution, UnetAttn2Execution):
|
||||
raise TypeError("Packed operation masks require an attn2 execution.")
|
||||
if not isinstance(inputs, torch.Tensor) or inputs.ndim != 3:
|
||||
raise ValueError("Packed operation inputs must use B/S/C layout.")
|
||||
if int(inputs.shape[0]) != execution.branches.packed_batch_size:
|
||||
raise ValueError("Packed operation batch must match attn2 branches.")
|
||||
if not isinstance(uses, Sequence) or not uses:
|
||||
raise ValueError("Packed operation masks require target uses.")
|
||||
composition = tuple(use.composition_index for use in uses)
|
||||
if composition != tuple(sorted(composition)):
|
||||
raise ValueError("Packed operation uses must follow composition order.")
|
||||
|
||||
@staticmethod
|
||||
def _packed_use_mask(
|
||||
execution: UnetAttn2Execution,
|
||||
*,
|
||||
use: RegionalOperationMaskUse,
|
||||
inputs: torch.Tensor,
|
||||
spatial: bool,
|
||||
) -> torch.Tensor:
|
||||
"""Return one use mask in exact compact branch-segment order."""
|
||||
|
||||
if use.region_index >= int(execution.query_masks.shape[0]):
|
||||
raise ValueError("Packed operation use references an unavailable region.")
|
||||
source_gate = REGIONAL_OPERATION_BRANCH_GATE_RESOLVER.resolve(
|
||||
execution.contexts,
|
||||
branch=use.branch,
|
||||
authority=inputs,
|
||||
)
|
||||
segments: list[torch.Tensor] = []
|
||||
for segment in execution.branches.segments:
|
||||
count = int(segment.source_indices.shape[0])
|
||||
if segment.key.region_index != use.region_index:
|
||||
segments.append(inputs.new_zeros((count, int(inputs.shape[1]), 1)))
|
||||
continue
|
||||
gate = source_gate.index_select(0, segment.source_indices).reshape(
|
||||
count,
|
||||
1,
|
||||
1,
|
||||
)
|
||||
if spatial:
|
||||
mask = execution.query_masks[use.region_index].index_select(
|
||||
0,
|
||||
segment.source_indices,
|
||||
)
|
||||
segments.append(mask.unsqueeze(-1) * gate)
|
||||
else:
|
||||
segments.append(gate.expand(-1, int(inputs.shape[1]), -1))
|
||||
return torch.cat(tuple(segments))
|
||||
|
||||
|
||||
STANDARD_UNET_PACKED_OPERATION_MASK_RESOLVER = StandardUnetPackedOperationMaskResolver()
|
||||
@@ -68,8 +68,7 @@ class RegionalLoraConditioningSourceCollector:
|
||||
|
||||
if not isinstance(plan, RawRegionalAttentionPlan):
|
||||
raise TypeError("Regional LoRA source collection requires a plan.")
|
||||
self._require_unhooked_base("positive", plan.positive)
|
||||
self._require_unhooked_base("negative", plan.negative)
|
||||
self._require_compatible_base_hooks(plan)
|
||||
return (
|
||||
*self._branch_sources(plan.positive, branch=RegionalLoraBranch.POSITIVE),
|
||||
*self._branch_sources(plan.negative, branch=RegionalLoraBranch.NEGATIVE),
|
||||
@@ -112,31 +111,49 @@ class RegionalLoraConditioningSourceCollector:
|
||||
)
|
||||
return tuple(sources)
|
||||
|
||||
def _require_unhooked_base(
|
||||
def _require_compatible_base_hooks(
|
||||
self,
|
||||
plan: RawRegionalAttentionPlan,
|
||||
) -> None:
|
||||
"""Require one shared global model-hook schedule across CFG branches."""
|
||||
|
||||
positive = self._base_hook_signature("positive", plan.positive)
|
||||
negative = self._base_hook_signature("negative", plan.negative)
|
||||
if positive != negative:
|
||||
raise ValueError(
|
||||
"Attention Coupling global model hooks must match across positive "
|
||||
"and negative conditioning. Encode both branches through the same "
|
||||
"Prompt Control global segment."
|
||||
)
|
||||
|
||||
def _base_hook_signature(
|
||||
self,
|
||||
branch_name: str,
|
||||
branch: RawRegionalAttentionBranch,
|
||||
) -> None:
|
||||
"""Require only model-active global LoRAs to arrive on the input MODEL."""
|
||||
) -> tuple[tuple[object, ...], ...]:
|
||||
"""Return one uniform global model-hook signature for a CFG branch."""
|
||||
|
||||
groups = conditioning_hook_groups(branch.base_conditioning)
|
||||
model_hook_count = sum(
|
||||
len(
|
||||
signatures = tuple(
|
||||
self._group_signature(
|
||||
self._model_hook_selection(
|
||||
group,
|
||||
source_label=(
|
||||
f"Attention Coupling {branch_name} global conditioning"
|
||||
),
|
||||
).model_hooks
|
||||
)
|
||||
)
|
||||
for group in groups
|
||||
for group in conditioning_hook_groups(branch.base_conditioning)
|
||||
)
|
||||
if model_hook_count:
|
||||
if not signatures:
|
||||
return ()
|
||||
authority = signatures[0]
|
||||
if any(signature != authority for signature in signatures[1:]):
|
||||
raise ValueError(
|
||||
f"Attention Coupling {branch_name} global conditioning contains "
|
||||
"model hooks. Apply global LoRAs to the input MODEL; reserve "
|
||||
"conditioning hooks for masked regional entries."
|
||||
f"Attention Coupling {branch_name} global conditioning uses "
|
||||
"different model HookGroups across text schedule entries. Keep "
|
||||
"model LoRA scheduling on one shared WeightHook schedule."
|
||||
)
|
||||
return authority
|
||||
|
||||
def _uniform_hooks(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Blend regional predictions inside ComfyUI calc-cond-batch execution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
from typing import Any, TypeAlias, cast
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.regional_detailing import LatentRegion
|
||||
from .tiled_sampling_validation import validate_tensor_shape
|
||||
|
||||
SAMPLER_LABEL = "Regional MultiDiffusion"
|
||||
CalcCondBatchFunction: TypeAlias = Callable[[dict[str, Any]], list[torch.Tensor]]
|
||||
|
||||
|
||||
class RegionalMultiDiffusionCalcCondBatch:
|
||||
"""Blend regional condition predictions before CFG is applied."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
global_prompt_weight: float,
|
||||
) -> None:
|
||||
"""Create a calc-cond-batch wrapper for one latent sampling shape."""
|
||||
|
||||
self._latent_width = latent_width
|
||||
self._latent_height = latent_height
|
||||
self._regions = regions
|
||||
self._existing_calc_cond_batch = existing_calc_cond_batch
|
||||
self._global_prompt_weight = global_prompt_weight
|
||||
|
||||
def __call__(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Return fallback predictions blended with regional predictions."""
|
||||
|
||||
x = args["input"]
|
||||
if not isinstance(x, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion model input must be a tensor.")
|
||||
validate_tensor_shape(x, sampler_label=SAMPLER_LABEL)
|
||||
if x.shape[-2:] != (self._latent_height, self._latent_width):
|
||||
return self._call_original(args)
|
||||
if not self._regions:
|
||||
return self._call_original(args)
|
||||
|
||||
timestep = args["sigma"]
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion sigma must be a tensor.")
|
||||
conds = args["conds"]
|
||||
if not isinstance(conds, list) or not conds:
|
||||
raise ValueError("Regional MultiDiffusion conds must be a non-empty list.")
|
||||
|
||||
fallback = self._call_original(args)
|
||||
regional_buffers = [torch.zeros_like(output) for output in fallback]
|
||||
regional_weights = [_new_spatial_weight(x) for _output in fallback]
|
||||
input_batch_size = int(x.shape[0])
|
||||
|
||||
for region in self._regions:
|
||||
region_slice = _region_slicer(region, x.ndim)
|
||||
region_x = x[region_slice]
|
||||
region_conds = [
|
||||
_prepare_region_conditioning(region.positive, args=args, x=region_x),
|
||||
*conds[1:],
|
||||
]
|
||||
region_args = args.copy()
|
||||
region_args["conds"] = region_conds
|
||||
region_args["input"] = region_x
|
||||
region_args["sigma"] = timestep
|
||||
region_outputs = self._call_original(region_args)
|
||||
self._accumulate_region_outputs(
|
||||
outputs=region_outputs,
|
||||
buffers=regional_buffers,
|
||||
weights=regional_weights,
|
||||
region=region,
|
||||
input_batch_size=input_batch_size,
|
||||
)
|
||||
|
||||
return [
|
||||
_blend_prediction(
|
||||
fallback_output,
|
||||
region_output,
|
||||
region_weight,
|
||||
global_prompt_weight=self._global_prompt_weight,
|
||||
)
|
||||
for fallback_output, region_output, region_weight in zip(
|
||||
fallback,
|
||||
regional_buffers,
|
||||
regional_weights,
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
|
||||
def _call_original(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Call the previous calc-cond-batch hook or ComfyUI default."""
|
||||
|
||||
clean_args = args.copy()
|
||||
clean_options = _clean_model_options(
|
||||
cast(dict[str, Any], clean_args["model_options"]),
|
||||
self._existing_calc_cond_batch,
|
||||
)
|
||||
clean_args["model_options"] = clean_options
|
||||
if self._existing_calc_cond_batch is not None:
|
||||
return self._existing_calc_cond_batch(clean_args)
|
||||
comfy_samplers = _comfy_samplers()
|
||||
return cast(
|
||||
list[torch.Tensor],
|
||||
comfy_samplers.calc_cond_batch(
|
||||
clean_args["model"],
|
||||
clean_args["conds"],
|
||||
clean_args["input"],
|
||||
clean_args["sigma"],
|
||||
clean_options,
|
||||
),
|
||||
)
|
||||
|
||||
def _accumulate_region_outputs(
|
||||
self,
|
||||
*,
|
||||
outputs: list[torch.Tensor],
|
||||
buffers: list[torch.Tensor],
|
||||
weights: list[torch.Tensor],
|
||||
region: LatentRegion,
|
||||
input_batch_size: int,
|
||||
) -> None:
|
||||
"""Accumulate one region prediction into full-latent buffers."""
|
||||
|
||||
for output_index, output in enumerate(outputs[: len(buffers)]):
|
||||
region_slice = _region_slicer(region, output.ndim)
|
||||
box = region.latent_box
|
||||
mask_slice = (
|
||||
region.latent_mask[
|
||||
box.y : box.y + box.height,
|
||||
box.x : box.x + box.width,
|
||||
]
|
||||
.reshape((1,) * (output.ndim - 2) + (box.height, box.width))
|
||||
.to(device=output.device, dtype=torch.float32)
|
||||
)
|
||||
buffers[output_index][region_slice] += output[
|
||||
:input_batch_size
|
||||
] * mask_slice.to(dtype=output.dtype)
|
||||
weights[output_index][region_slice] += mask_slice
|
||||
|
||||
|
||||
def validate_regions(
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
) -> None:
|
||||
"""Reject regions incompatible with the current latent shape."""
|
||||
|
||||
for region in regions:
|
||||
box = region.latent_box
|
||||
if region.latent_mask.shape != (latent_height, latent_width):
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent_mask must match the full latent height and width."
|
||||
)
|
||||
if box.x < 0 or box.y < 0 or box.width < 1 or box.height < 1:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"has an invalid latent box."
|
||||
)
|
||||
if box.x + box.width > latent_width or box.y + box.height > latent_height:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent box must fit inside the latent."
|
||||
)
|
||||
|
||||
|
||||
def _region_slicer(region: LatentRegion, tensor_ndim: int) -> tuple[slice, ...]:
|
||||
"""Return a slicer that crops a tensor to a latent region box."""
|
||||
|
||||
box = region.latent_box
|
||||
return (
|
||||
(slice(None),) * (tensor_ndim - 2)
|
||||
+ (slice(box.y, box.y + box.height),)
|
||||
+ (slice(box.x, box.x + box.width),)
|
||||
)
|
||||
|
||||
|
||||
def _new_spatial_weight(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Create a full-latent spatial weight buffer."""
|
||||
|
||||
return torch.zeros(
|
||||
(1,) * (x.ndim - 2) + (int(x.shape[-2]), int(x.shape[-1])),
|
||||
device=x.device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def _blend_prediction(
|
||||
fallback: torch.Tensor,
|
||||
regional: torch.Tensor,
|
||||
weight: torch.Tensor,
|
||||
*,
|
||||
global_prompt_weight: float,
|
||||
) -> torch.Tensor:
|
||||
"""Blend normalized regional predictions with global fallback predictions."""
|
||||
|
||||
has_region = weight > 0
|
||||
normalized = torch.where(
|
||||
has_region,
|
||||
regional / torch.clamp(weight, min=1.0e-37).to(dtype=regional.dtype),
|
||||
regional,
|
||||
)
|
||||
coverage = torch.clamp(weight, 0.0, 1.0).to(dtype=fallback.dtype)
|
||||
regional_alpha = coverage * (1.0 - global_prompt_weight)
|
||||
blended = fallback * (1.0 - regional_alpha) + normalized * regional_alpha
|
||||
return torch.where(has_region, blended, fallback)
|
||||
|
||||
|
||||
def _prepare_region_conditioning(
|
||||
conditioning: object,
|
||||
*,
|
||||
args: dict[str, Any],
|
||||
x: torch.Tensor,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert raw Comfy CONDITIONING into sampler-ready dictionaries."""
|
||||
|
||||
if _is_processed_conditioning(conditioning):
|
||||
return cast(list[dict[str, Any]], conditioning)
|
||||
if not isinstance(conditioning, list):
|
||||
raise TypeError("Regional MultiDiffusion region_positive must be CONDITIONING.")
|
||||
sampler_helpers = _comfy_sampler_helpers()
|
||||
comfy_samplers = _comfy_samplers()
|
||||
model = args["model"]
|
||||
converted = cast(list[dict[str, Any]], sampler_helpers.convert_cond(conditioning))
|
||||
comfy_samplers.resolve_areas_and_cond_masks_multidim(
|
||||
converted,
|
||||
tuple(int(dim) for dim in x.shape[2:]),
|
||||
x.device,
|
||||
)
|
||||
comfy_samplers.calculate_start_end_timesteps(model, converted)
|
||||
if hasattr(model, "extra_conds"):
|
||||
converted = cast(
|
||||
list[dict[str, Any]],
|
||||
comfy_samplers.encode_model_conds(
|
||||
model.extra_conds,
|
||||
converted,
|
||||
x,
|
||||
x.device,
|
||||
"positive",
|
||||
),
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
def _is_processed_conditioning(conditioning: object) -> bool:
|
||||
"""Return whether a conditioning value is already sampler-ready."""
|
||||
|
||||
if not isinstance(conditioning, list):
|
||||
return False
|
||||
if not conditioning:
|
||||
return True
|
||||
return all(
|
||||
isinstance(item, dict) and "model_conds" in item for item in conditioning
|
||||
)
|
||||
|
||||
|
||||
def _clean_model_options(
|
||||
model_options: dict[str, Any],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return model options that cannot recurse into this wrapper."""
|
||||
|
||||
clean_options = model_options.copy()
|
||||
if existing_calc_cond_batch is None:
|
||||
clean_options.pop("sampler_calc_cond_batch_function", None)
|
||||
else:
|
||||
clean_options["sampler_calc_cond_batch_function"] = existing_calc_cond_batch
|
||||
return clean_options
|
||||
|
||||
|
||||
def _comfy_samplers() -> ModuleType:
|
||||
"""Import Comfy sampler helpers lazily."""
|
||||
|
||||
return import_module("comfy.samplers")
|
||||
|
||||
|
||||
def _comfy_sampler_helpers() -> ModuleType:
|
||||
"""Import Comfy sampler conditioning helpers lazily."""
|
||||
|
||||
return import_module("comfy.sampler_helpers")
|
||||
@@ -10,13 +10,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
from typing import Any, TypeAlias, cast
|
||||
|
||||
import torch
|
||||
from typing import Any, cast
|
||||
|
||||
from ..domain.regional_detailing import LatentRegion
|
||||
from ..domain.regional_features import EMPTY_REGIONAL_CAPABILITY_ADMISSION
|
||||
@@ -27,8 +24,14 @@ from .differential_diffusion import (
|
||||
differential_diffusion_mutation,
|
||||
has_denoise_mask_function,
|
||||
)
|
||||
from .guided_sampling import sample_with_optional_negative
|
||||
from .model_patcher_mutations import ModelCalcCondBatchMutation
|
||||
from .patcher_lifecycle import PATCHER_LIFECYCLE, ModelMutation
|
||||
from .regional_multidiffusion_prediction import (
|
||||
CalcCondBatchFunction,
|
||||
RegionalMultiDiffusionCalcCondBatch,
|
||||
validate_regions,
|
||||
)
|
||||
from .tiled_sampling_validation import (
|
||||
Latent,
|
||||
reject_unsupported_conditioning,
|
||||
@@ -39,7 +42,6 @@ from .tiled_sampling_validation import (
|
||||
LOGGER = get_logger(__name__)
|
||||
SAMPLER_LABEL = "Regional MultiDiffusion"
|
||||
UNIPC_SAMPLERS = frozenset({"uni_pc", "uni_pc_bh2"})
|
||||
CalcCondBatchFunction: TypeAlias = Callable[[dict[str, Any]], list[torch.Tensor]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -136,15 +138,16 @@ def sample_regional_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,
|
||||
@@ -193,7 +196,7 @@ def clone_model_with_regional_multidiffusion(
|
||||
denoise=1.0,
|
||||
global_prompt_weight=global_prompt_weight,
|
||||
)
|
||||
_validate_regions(
|
||||
validate_regions(
|
||||
latent_width=latent_width,
|
||||
latent_height=latent_height,
|
||||
regions=regions,
|
||||
@@ -234,220 +237,6 @@ def clone_model_with_regional_multidiffusion(
|
||||
return derived_model, summary
|
||||
|
||||
|
||||
class RegionalMultiDiffusionCalcCondBatch:
|
||||
"""Blend regional condition predictions before CFG is applied."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
global_prompt_weight: float,
|
||||
) -> None:
|
||||
"""Create a calc-cond-batch wrapper for one latent sampling shape."""
|
||||
|
||||
self._latent_width = latent_width
|
||||
self._latent_height = latent_height
|
||||
self._regions = regions
|
||||
self._existing_calc_cond_batch = existing_calc_cond_batch
|
||||
self._global_prompt_weight = global_prompt_weight
|
||||
|
||||
def __call__(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Return fallback predictions blended with regional predictions."""
|
||||
|
||||
x = args["input"]
|
||||
if not isinstance(x, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion model input must be a tensor.")
|
||||
validate_tensor_shape(x, sampler_label=SAMPLER_LABEL)
|
||||
if x.shape[-2:] != (self._latent_height, self._latent_width):
|
||||
return self._call_original(args)
|
||||
if not self._regions:
|
||||
return self._call_original(args)
|
||||
|
||||
timestep = args["sigma"]
|
||||
if not isinstance(timestep, torch.Tensor):
|
||||
raise ValueError("Regional MultiDiffusion sigma must be a tensor.")
|
||||
conds = args["conds"]
|
||||
if not isinstance(conds, list) or not conds:
|
||||
raise ValueError("Regional MultiDiffusion conds must be a non-empty list.")
|
||||
|
||||
fallback = self._call_original(args)
|
||||
regional_buffers = [torch.zeros_like(output) for output in fallback]
|
||||
regional_weights = [_new_spatial_weight(x) for _output in fallback]
|
||||
input_batch_size = int(x.shape[0])
|
||||
|
||||
for region in self._regions:
|
||||
region_slice = _region_slicer(region, x.ndim)
|
||||
region_x = x[region_slice]
|
||||
region_conds = [
|
||||
_prepare_region_conditioning(
|
||||
region.positive,
|
||||
args=args,
|
||||
x=region_x,
|
||||
),
|
||||
*conds[1:],
|
||||
]
|
||||
region_args = args.copy()
|
||||
region_args["conds"] = region_conds
|
||||
region_args["input"] = region_x
|
||||
region_args["sigma"] = timestep
|
||||
region_outputs = self._call_original(region_args)
|
||||
self._accumulate_region_outputs(
|
||||
outputs=region_outputs,
|
||||
buffers=regional_buffers,
|
||||
weights=regional_weights,
|
||||
region=region,
|
||||
input_batch_size=input_batch_size,
|
||||
)
|
||||
|
||||
return [
|
||||
_blend_prediction(
|
||||
fallback_output,
|
||||
region_output,
|
||||
region_weight,
|
||||
global_prompt_weight=self._global_prompt_weight,
|
||||
)
|
||||
for fallback_output, region_output, region_weight in zip(
|
||||
fallback,
|
||||
regional_buffers,
|
||||
regional_weights,
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
|
||||
def _call_original(self, args: dict[str, Any]) -> list[torch.Tensor]:
|
||||
"""Call the previous calc-cond-batch hook or ComfyUI default."""
|
||||
|
||||
clean_args = args.copy()
|
||||
clean_options = _clean_model_options(
|
||||
cast(dict[str, Any], clean_args["model_options"]),
|
||||
self._existing_calc_cond_batch,
|
||||
)
|
||||
clean_args["model_options"] = clean_options
|
||||
if self._existing_calc_cond_batch is not None:
|
||||
return self._existing_calc_cond_batch(clean_args)
|
||||
comfy_samplers = _comfy_samplers()
|
||||
return cast(
|
||||
list[torch.Tensor],
|
||||
comfy_samplers.calc_cond_batch(
|
||||
clean_args["model"],
|
||||
clean_args["conds"],
|
||||
clean_args["input"],
|
||||
clean_args["sigma"],
|
||||
clean_options,
|
||||
),
|
||||
)
|
||||
|
||||
def _accumulate_region_outputs(
|
||||
self,
|
||||
*,
|
||||
outputs: list[torch.Tensor],
|
||||
buffers: list[torch.Tensor],
|
||||
weights: list[torch.Tensor],
|
||||
region: LatentRegion,
|
||||
input_batch_size: int,
|
||||
) -> None:
|
||||
"""Accumulate one region prediction into full-latent buffers."""
|
||||
|
||||
for output_index, output in enumerate(outputs[: len(buffers)]):
|
||||
region_slice = _region_slicer(region, output.ndim)
|
||||
box = region.latent_box
|
||||
mask_slice = (
|
||||
region.latent_mask[
|
||||
box.y : box.y + box.height,
|
||||
box.x : box.x + box.width,
|
||||
]
|
||||
.reshape((1,) * (output.ndim - 2) + (box.height, box.width))
|
||||
.to(device=output.device, dtype=torch.float32)
|
||||
)
|
||||
buffers[output_index][region_slice] += output[
|
||||
:input_batch_size
|
||||
] * mask_slice.to(dtype=output.dtype)
|
||||
weights[output_index][region_slice] += mask_slice
|
||||
|
||||
|
||||
def _validate_regions(
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
regions: tuple[LatentRegion, ...],
|
||||
) -> None:
|
||||
"""Reject regions incompatible with the current latent shape."""
|
||||
|
||||
for region in regions:
|
||||
_validate_region(region, latent_width=latent_width, latent_height=latent_height)
|
||||
|
||||
|
||||
def _validate_region(
|
||||
region: LatentRegion,
|
||||
*,
|
||||
latent_width: int,
|
||||
latent_height: int,
|
||||
) -> None:
|
||||
"""Reject regions incompatible with the current latent shape."""
|
||||
|
||||
box = region.latent_box
|
||||
if region.latent_mask.shape != (latent_height, latent_width):
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent_mask must match the full latent height and width."
|
||||
)
|
||||
if box.x < 0 or box.y < 0 or box.width < 1 or box.height < 1:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"has an invalid latent box."
|
||||
)
|
||||
if box.x + box.width > latent_width or box.y + box.height > latent_height:
|
||||
raise ValueError(
|
||||
f"Regional MultiDiffusion region {region.index} ('{region.label}') "
|
||||
"latent box must fit inside the latent."
|
||||
)
|
||||
|
||||
|
||||
def _region_slicer(region: LatentRegion, tensor_ndim: int) -> tuple[slice, ...]:
|
||||
"""Return a slicer that crops a tensor to a latent region box."""
|
||||
|
||||
box = region.latent_box
|
||||
return (
|
||||
(slice(None),) * (tensor_ndim - 2)
|
||||
+ (slice(box.y, box.y + box.height),)
|
||||
+ (slice(box.x, box.x + box.width),)
|
||||
)
|
||||
|
||||
|
||||
def _new_spatial_weight(x: torch.Tensor) -> torch.Tensor:
|
||||
"""Create a full-latent spatial weight buffer."""
|
||||
|
||||
return torch.zeros(
|
||||
(1,) * (x.ndim - 2) + (int(x.shape[-2]), int(x.shape[-1])),
|
||||
device=x.device,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def _blend_prediction(
|
||||
fallback: torch.Tensor,
|
||||
regional: torch.Tensor,
|
||||
weight: torch.Tensor,
|
||||
*,
|
||||
global_prompt_weight: float,
|
||||
) -> torch.Tensor:
|
||||
"""Blend normalized regional predictions with global fallback predictions."""
|
||||
|
||||
has_region = weight > 0
|
||||
normalized = torch.where(
|
||||
has_region,
|
||||
regional / torch.clamp(weight, min=1.0e-37).to(dtype=regional.dtype),
|
||||
regional,
|
||||
)
|
||||
coverage = torch.clamp(weight, 0.0, 1.0).to(dtype=fallback.dtype)
|
||||
regional_alpha = coverage * (1.0 - global_prompt_weight)
|
||||
blended = fallback * (1.0 - regional_alpha) + normalized * regional_alpha
|
||||
return torch.where(has_region, blended, fallback)
|
||||
|
||||
|
||||
def _validate_sampling_controls(
|
||||
*,
|
||||
steps: int,
|
||||
@@ -470,69 +259,6 @@ def _validate_global_prompt_weight(global_prompt_weight: float) -> None:
|
||||
raise ValueError("global_prompt_weight must be between 0.0 and 1.0.")
|
||||
|
||||
|
||||
def _prepare_region_conditioning(
|
||||
conditioning: object,
|
||||
*,
|
||||
args: dict[str, Any],
|
||||
x: torch.Tensor,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Convert raw Comfy CONDITIONING into sampler-ready condition dictionaries."""
|
||||
|
||||
if _is_processed_conditioning(conditioning):
|
||||
return cast(list[dict[str, Any]], conditioning)
|
||||
if not isinstance(conditioning, list):
|
||||
raise TypeError("Regional MultiDiffusion region_positive must be CONDITIONING.")
|
||||
|
||||
sampler_helpers = _comfy_sampler_helpers()
|
||||
comfy_samplers = _comfy_samplers()
|
||||
model = args["model"]
|
||||
converted = cast(list[dict[str, Any]], sampler_helpers.convert_cond(conditioning))
|
||||
comfy_samplers.resolve_areas_and_cond_masks_multidim(
|
||||
converted,
|
||||
tuple(int(dim) for dim in x.shape[2:]),
|
||||
x.device,
|
||||
)
|
||||
comfy_samplers.calculate_start_end_timesteps(model, converted)
|
||||
if hasattr(model, "extra_conds"):
|
||||
converted = cast(
|
||||
list[dict[str, Any]],
|
||||
comfy_samplers.encode_model_conds(
|
||||
model.extra_conds,
|
||||
converted,
|
||||
x,
|
||||
x.device,
|
||||
"positive",
|
||||
),
|
||||
)
|
||||
return converted
|
||||
|
||||
|
||||
def _is_processed_conditioning(conditioning: object) -> bool:
|
||||
"""Return whether a conditioning value is already sampler-ready."""
|
||||
|
||||
if not isinstance(conditioning, list):
|
||||
return False
|
||||
if not conditioning:
|
||||
return True
|
||||
return all(
|
||||
isinstance(item, dict) and "model_conds" in item for item in conditioning
|
||||
)
|
||||
|
||||
|
||||
def _clean_model_options(
|
||||
model_options: dict[str, Any],
|
||||
existing_calc_cond_batch: CalcCondBatchFunction | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return model options that cannot recurse into this wrapper."""
|
||||
|
||||
clean_options = model_options.copy()
|
||||
if existing_calc_cond_batch is None:
|
||||
clean_options.pop("sampler_calc_cond_batch_function", None)
|
||||
else:
|
||||
clean_options["sampler_calc_cond_batch_function"] = existing_calc_cond_batch
|
||||
return clean_options
|
||||
|
||||
|
||||
def _reject_unipc_sampler(sampler_name: str) -> None:
|
||||
"""Reject UniPC samplers because MultiDiffusion is incompatible with them."""
|
||||
|
||||
@@ -566,18 +292,6 @@ def _comfy_utils() -> ModuleType:
|
||||
return import_module("comfy.utils")
|
||||
|
||||
|
||||
def _comfy_samplers() -> ModuleType:
|
||||
"""Import ComfyUI sampler helpers lazily."""
|
||||
|
||||
return import_module("comfy.samplers")
|
||||
|
||||
|
||||
def _comfy_sampler_helpers() -> ModuleType:
|
||||
"""Import ComfyUI conditioning conversion helpers lazily."""
|
||||
|
||||
return import_module("comfy.sampler_helpers")
|
||||
|
||||
|
||||
def _latent_preview() -> ModuleType:
|
||||
"""Import ComfyUI preview helpers lazily."""
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from typing import Any, Protocol
|
||||
|
||||
import torch
|
||||
|
||||
from ..services.simple_preview_segs_service import SegPreviewDocument
|
||||
from ..domain.seg_preview import SegPreviewDocument
|
||||
|
||||
SEG_PREVIEW_UI_KEY = "simple_syrup_segs_preview"
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ from dataclasses import dataclass
|
||||
import torch
|
||||
|
||||
from ..domain.segs import BoundingBox
|
||||
from ..masking.segs_mask_ops import normalize_mask
|
||||
from .ultralytics_loader import UltralyticsDetectorModel
|
||||
from ..domain.segs_mask_ops import normalize_mask
|
||||
from .ultralytics_model_adapter import UltralyticsDetectorModel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -1,544 +0,0 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Ultralytics detector model discovery and lazy loading."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, TypeAlias, cast
|
||||
|
||||
from ..shared.logging import get_logger
|
||||
from .model_catalog import ULTRALYTICS_ENTRIES, ModelEntry
|
||||
from .model_choices import ModelChoiceService
|
||||
from .model_downloads import DownloadRequest, ModelDownloader, ProgressReporter
|
||||
from .model_folders import (
|
||||
SUPPORTED_MODEL_EXTENSIONS,
|
||||
expected_model_file,
|
||||
resolve_model_file,
|
||||
)
|
||||
from .model_instance_cache import ModelInstanceCache
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
NO_LOCAL_ULTRALYTICS_MODELS = "No local Ultralytics models found"
|
||||
ULTRALYTICS_FOLDER = "ultralytics"
|
||||
ULTRALYTICS_BBOX_FOLDER = "ultralytics_bbox"
|
||||
ULTRALYTICS_SEGM_FOLDER = "ultralytics_segm"
|
||||
|
||||
ModelFolderRegistry: TypeAlias = dict[str, tuple[list[str], set[str]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltralyticsDetectorModel:
|
||||
"""Store a loaded Ultralytics detector with SimpleSyrup metadata."""
|
||||
|
||||
model_name: str
|
||||
model_path: Path
|
||||
model: Any
|
||||
task: str
|
||||
names: dict[int, str]
|
||||
supports_segmentation: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoadedUltralyticsDetector:
|
||||
"""Bundle native and compatibility detector outputs from the loader."""
|
||||
|
||||
detector_model: UltralyticsDetectorModel
|
||||
bbox_detector: object
|
||||
segm_detector: object
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltralyticsModelCacheKey:
|
||||
"""Identify a loaded Ultralytics detector for process-level reuse."""
|
||||
|
||||
model_path: Path
|
||||
|
||||
|
||||
_LOADED_ULTRALYTICS_MODELS: dict[
|
||||
UltralyticsModelCacheKey, LoadedUltralyticsDetector
|
||||
] = {}
|
||||
|
||||
|
||||
class UltralyticsLoaderService:
|
||||
"""Discover and load Ultralytics detector models from ComfyUI folders."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
folder_paths_module: ModuleType | None = None,
|
||||
ultralytics_module: ModuleType | None = None,
|
||||
downloader: ModelDownloader | None = None,
|
||||
choice_service: ModelChoiceService | None = None,
|
||||
cache: (
|
||||
MutableMapping[UltralyticsModelCacheKey, LoadedUltralyticsDetector] | None
|
||||
) = None,
|
||||
) -> None:
|
||||
"""Create the loader with injectable runtime modules for tests."""
|
||||
|
||||
self._folder_paths_module = folder_paths_module
|
||||
self._ultralytics_module = ultralytics_module
|
||||
self._downloader = downloader or ModelDownloader()
|
||||
self._choice_service = choice_service or ModelChoiceService()
|
||||
self._cache: ModelInstanceCache[
|
||||
UltralyticsModelCacheKey, LoadedUltralyticsDetector
|
||||
] = ModelInstanceCache(
|
||||
cache if cache is not None else _LOADED_ULTRALYTICS_MODELS
|
||||
)
|
||||
|
||||
def model_choices(self) -> list[str]:
|
||||
"""Return installed choices first, followed by downloadable catalog choices."""
|
||||
|
||||
self._register_model_folders()
|
||||
curated_choices = self._choice_service.ultralytics_choices()
|
||||
catalog_choice_labels = {
|
||||
_catalog_selection(entry): entry.display_name
|
||||
for entry in ULTRALYTICS_ENTRIES
|
||||
}
|
||||
available_choices = self.available_models()
|
||||
visible_catalog_choices = set(curated_choices)
|
||||
installed_catalog_choices = [
|
||||
entry.display_name
|
||||
for entry in ULTRALYTICS_ENTRIES
|
||||
if (
|
||||
entry.display_name in visible_catalog_choices
|
||||
and _catalog_selection(entry) in available_choices
|
||||
)
|
||||
]
|
||||
installed_non_catalog_choices = [
|
||||
choice
|
||||
for choice in available_choices
|
||||
if choice not in catalog_choice_labels
|
||||
]
|
||||
downloadable_choices = [
|
||||
choice
|
||||
for choice in curated_choices
|
||||
if choice not in installed_catalog_choices
|
||||
]
|
||||
choices = (
|
||||
installed_non_catalog_choices
|
||||
+ installed_catalog_choices
|
||||
+ downloadable_choices
|
||||
)
|
||||
return choices or [NO_LOCAL_ULTRALYTICS_MODELS]
|
||||
|
||||
def available_models(self) -> list[str]:
|
||||
"""Return supported model files in registered Ultralytics folders."""
|
||||
|
||||
self._register_model_folders()
|
||||
folder_paths = self._folder_paths()
|
||||
choices: set[str] = set()
|
||||
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
choices.update(path.name for path in _supported_files(folder))
|
||||
bbox_dir = folder / "bbox"
|
||||
segm_dir = folder / "segm"
|
||||
choices.update(f"bbox/{path.name}" for path in _supported_files(bbox_dir))
|
||||
choices.update(f"segm/{path.name}" for path in _supported_files(segm_dir))
|
||||
|
||||
for path in self._folder_paths_for(ULTRALYTICS_BBOX_FOLDER):
|
||||
choices.update(f"bbox/{file.name}" for file in _supported_files(path))
|
||||
for path in self._folder_paths_for(ULTRALYTICS_SEGM_FOLDER):
|
||||
choices.update(f"segm/{file.name}" for file in _supported_files(path))
|
||||
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
for folder_name in (
|
||||
ULTRALYTICS_FOLDER,
|
||||
ULTRALYTICS_BBOX_FOLDER,
|
||||
ULTRALYTICS_SEGM_FOLDER,
|
||||
):
|
||||
if folder_name not in registry:
|
||||
continue
|
||||
for filename in folder_paths.get_filename_list(folder_name):
|
||||
path = Path(str(filename))
|
||||
if path.suffix.lower() not in SUPPORTED_MODEL_EXTENSIONS:
|
||||
continue
|
||||
if folder_name == ULTRALYTICS_BBOX_FOLDER:
|
||||
choices.add(f"bbox/{path.name}")
|
||||
elif folder_name == ULTRALYTICS_SEGM_FOLDER:
|
||||
choices.add(f"segm/{path.name}")
|
||||
else:
|
||||
choices.add(path.as_posix())
|
||||
|
||||
return sorted(choices)
|
||||
|
||||
def load(
|
||||
self,
|
||||
model_name: str,
|
||||
progress: ProgressReporter | None = None,
|
||||
) -> LoadedUltralyticsDetector:
|
||||
"""Load one Ultralytics model and create compatibility facades."""
|
||||
|
||||
self.reject_sentinel(model_name)
|
||||
entry = _catalog_entry_or_none(model_name)
|
||||
if entry is None:
|
||||
model_path = self.resolve_model_path(model_name)
|
||||
normalized_name = _normalized_model_name(model_name)
|
||||
else:
|
||||
model_path = self._resolve_catalog_entry(entry, progress)
|
||||
normalized_name = _catalog_selection(entry)
|
||||
|
||||
key = UltralyticsModelCacheKey(model_path=model_path.resolve())
|
||||
already_loaded = key in self._cache.entries
|
||||
loaded = self._cache.get_or_load(
|
||||
key,
|
||||
lambda: self._load_uncached_detector(normalized_name, model_path),
|
||||
)
|
||||
if already_loaded:
|
||||
LOGGER.info(
|
||||
"Ultralytics model loaded from process cache",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": normalized_name,
|
||||
"model_path": str(model_path),
|
||||
"task": loaded.detector_model.task,
|
||||
},
|
||||
)
|
||||
return loaded
|
||||
|
||||
def _resolve_catalog_entry(
|
||||
self,
|
||||
entry: ModelEntry,
|
||||
progress: ProgressReporter | None,
|
||||
) -> Path:
|
||||
"""Resolve or securely download one curated Ultralytics checkpoint."""
|
||||
|
||||
if len(entry.artifacts) != 1:
|
||||
raise RuntimeError(
|
||||
f"Ultralytics catalog entry '{entry.entry_id}' must have one artifact."
|
||||
)
|
||||
|
||||
self._register_model_folders()
|
||||
artifact = entry.artifacts[0]
|
||||
existing = resolve_model_file(
|
||||
artifact.folder_name,
|
||||
artifact.filename,
|
||||
self._folder_paths_module,
|
||||
)
|
||||
destination = existing or expected_model_file(
|
||||
artifact.folder_name, artifact.filename, self._folder_paths_module
|
||||
)
|
||||
result = self._downloader.download(
|
||||
DownloadRequest(
|
||||
source_url=artifact.source_url,
|
||||
destination_path=destination,
|
||||
expected_folder=destination.parent,
|
||||
description=artifact.description,
|
||||
expected_sha256=artifact.sha256,
|
||||
),
|
||||
progress,
|
||||
)
|
||||
return result.path
|
||||
|
||||
def _load_uncached_detector(
|
||||
self,
|
||||
model_name: str,
|
||||
model_path: Path,
|
||||
) -> LoadedUltralyticsDetector:
|
||||
"""Load an Ultralytics detector after path resolution and cache lookup."""
|
||||
|
||||
ultralytics_module = self._ultralytics()
|
||||
model_class = getattr(ultralytics_module, "YOLO", None)
|
||||
if model_class is None:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires a module exposing the YOLO class."
|
||||
)
|
||||
|
||||
try:
|
||||
raw_model = model_class(str(model_path))
|
||||
except Exception as exc:
|
||||
LOGGER.error(
|
||||
"Failed to load Ultralytics model",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Ultralytics model '{model_name}' could not be loaded from "
|
||||
f"'{model_path}'."
|
||||
) from exc
|
||||
|
||||
task = _model_task(model_name, raw_model)
|
||||
device_hint = _model_device_hint(raw_model)
|
||||
detector_model = UltralyticsDetectorModel(
|
||||
model_name=model_name,
|
||||
model_path=model_path,
|
||||
model=raw_model,
|
||||
task=task,
|
||||
names=_model_names(raw_model),
|
||||
supports_segmentation=task in {"segment", "segm"},
|
||||
)
|
||||
|
||||
from .detector_compat import BBoxDetectorFacade, SegmDetectorFacade
|
||||
|
||||
bbox_detector = BBoxDetectorFacade(detector_model)
|
||||
segm_detector = SegmDetectorFacade(detector_model, bbox_detector)
|
||||
LOGGER.info(
|
||||
"Ultralytics model loaded",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
"task": task,
|
||||
"device": device_hint,
|
||||
},
|
||||
)
|
||||
return LoadedUltralyticsDetector(
|
||||
detector_model=detector_model,
|
||||
bbox_detector=bbox_detector,
|
||||
segm_detector=segm_detector,
|
||||
)
|
||||
|
||||
def reject_sentinel(self, model_name: str) -> None:
|
||||
"""Reject placeholder dropdown selections before filesystem work."""
|
||||
|
||||
if model_name == NO_LOCAL_ULTRALYTICS_MODELS:
|
||||
raise ValueError(
|
||||
"No local Ultralytics models are available. Enable 'Show "
|
||||
"downloadable models in loader dropdowns' in SimpleSyrup settings "
|
||||
"or install a model in models\\ultralytics, "
|
||||
"models\\ultralytics\\bbox, or models\\ultralytics\\segm."
|
||||
)
|
||||
|
||||
def resolve_model_path(self, model_name: str) -> Path:
|
||||
"""Resolve a safe model choice to a file inside ComfyUI model folders."""
|
||||
|
||||
safe_name = Path(model_name.replace("\\", "/"))
|
||||
if safe_name.is_absolute() or ".." in safe_name.parts:
|
||||
raise ValueError(
|
||||
f"Ultralytics model name '{model_name}' is not a safe relative path."
|
||||
)
|
||||
|
||||
candidates = self._candidate_paths(safe_name)
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
raise ValueError(
|
||||
f"Ultralytics model '{model_name}' was not found in configured "
|
||||
"ComfyUI model folders."
|
||||
)
|
||||
|
||||
def _candidate_paths(self, model_name: Path) -> list[Path]:
|
||||
"""Return bounded filesystem candidates for a model choice."""
|
||||
|
||||
self._register_model_folders()
|
||||
candidates: list[Path] = []
|
||||
parts = model_name.parts
|
||||
if len(parts) >= 2 and parts[0] == "bbox":
|
||||
relative = Path(*parts[1:])
|
||||
candidates.extend(
|
||||
folder / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_BBOX_FOLDER)
|
||||
)
|
||||
candidates.extend(
|
||||
folder / "bbox" / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER)
|
||||
)
|
||||
elif len(parts) >= 2 and parts[0] == "segm":
|
||||
relative = Path(*parts[1:])
|
||||
candidates.extend(
|
||||
folder / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_SEGM_FOLDER)
|
||||
)
|
||||
candidates.extend(
|
||||
folder / "segm" / relative
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER)
|
||||
)
|
||||
else:
|
||||
candidates.extend(
|
||||
folder / model_name
|
||||
for folder in self._folder_paths_for(ULTRALYTICS_FOLDER)
|
||||
)
|
||||
return candidates
|
||||
|
||||
def _register_model_folders(self) -> None:
|
||||
"""Register conventional Ultralytics folders with ComfyUI when possible."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
add_model_folder_path = getattr(folder_paths, "add_model_folder_path", None)
|
||||
if add_model_folder_path is None:
|
||||
return
|
||||
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
registrations = (
|
||||
(ULTRALYTICS_FOLDER, models_dir / "ultralytics"),
|
||||
(ULTRALYTICS_BBOX_FOLDER, models_dir / "ultralytics" / "bbox"),
|
||||
(ULTRALYTICS_SEGM_FOLDER, models_dir / "ultralytics" / "segm"),
|
||||
)
|
||||
for folder_name, path in registrations:
|
||||
if folder_name in registry:
|
||||
continue
|
||||
add_model_folder_path(folder_name, str(path))
|
||||
|
||||
def _folder_paths_for(self, folder_name: str) -> list[Path]:
|
||||
"""Return registered paths for one ComfyUI model folder."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
fallback = {
|
||||
ULTRALYTICS_FOLDER: models_dir / "ultralytics",
|
||||
ULTRALYTICS_BBOX_FOLDER: models_dir / "ultralytics" / "bbox",
|
||||
ULTRALYTICS_SEGM_FOLDER: models_dir / "ultralytics" / "segm",
|
||||
}[folder_name]
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
paths = [fallback]
|
||||
if folder_name in registry:
|
||||
paths = [Path(str(path)) for path in registry[folder_name][0]] + paths
|
||||
return _unique_paths(paths)
|
||||
|
||||
def _folder_paths(self) -> ModuleType:
|
||||
"""Import ComfyUI folder path helpers lazily."""
|
||||
|
||||
if self._folder_paths_module is not None:
|
||||
return self._folder_paths_module
|
||||
module = importlib.import_module("folder_paths")
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("folder_paths import did not return a module.")
|
||||
self._folder_paths_module = module
|
||||
return module
|
||||
|
||||
def _ultralytics(self) -> ModuleType:
|
||||
"""Import Ultralytics lazily and fail with an actionable message."""
|
||||
|
||||
if self._ultralytics_module is not None:
|
||||
return self._ultralytics_module
|
||||
try:
|
||||
module = importlib.import_module("ultralytics")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires the 'ultralytics' package in the "
|
||||
"ComfyUI virtual environment."
|
||||
) from exc
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("ultralytics import did not return a module.")
|
||||
self._ultralytics_module = module
|
||||
return module
|
||||
|
||||
|
||||
def _supported_files(folder: Path) -> list[Path]:
|
||||
"""Return directly contained supported model files for a folder."""
|
||||
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in SUPPORTED_MODEL_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def _unique_paths(paths: list[Path]) -> list[Path]:
|
||||
"""Return unique paths while preserving order."""
|
||||
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
key = str(path)
|
||||
if key in seen:
|
||||
continue
|
||||
unique.append(path)
|
||||
seen.add(key)
|
||||
return unique
|
||||
|
||||
|
||||
def _normalized_model_name(model_name: str) -> str:
|
||||
"""Return a stable model selection string for cache identity."""
|
||||
|
||||
return model_name.replace("\\", "/")
|
||||
|
||||
|
||||
def _catalog_entry_or_none(selection: str) -> ModelEntry | None:
|
||||
"""Return a curated Ultralytics entry when a dropdown label matches it."""
|
||||
|
||||
return next(
|
||||
(
|
||||
entry
|
||||
for entry in ULTRALYTICS_ENTRIES
|
||||
if selection in (entry.entry_id, entry.display_name)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _catalog_selection(entry: ModelEntry) -> str:
|
||||
"""Return the local conventional selection path for one catalog entry."""
|
||||
|
||||
if len(entry.artifacts) != 1:
|
||||
raise ValueError(
|
||||
f"Ultralytics catalog entry '{entry.entry_id}' must have one artifact."
|
||||
)
|
||||
|
||||
artifact = entry.artifacts[0]
|
||||
prefix_by_folder = {
|
||||
ULTRALYTICS_BBOX_FOLDER: "bbox",
|
||||
ULTRALYTICS_SEGM_FOLDER: "segm",
|
||||
}
|
||||
try:
|
||||
prefix = prefix_by_folder[artifact.folder_name]
|
||||
except KeyError as error:
|
||||
raise ValueError(
|
||||
f"Ultralytics catalog entry '{entry.entry_id}' has unsupported folder "
|
||||
f"'{artifact.folder_name}'."
|
||||
) from error
|
||||
return f"{prefix}/{artifact.filename}"
|
||||
|
||||
|
||||
def _model_task(model_name: str, raw_model: object) -> str:
|
||||
"""Infer detector task from choice prefix or model metadata."""
|
||||
|
||||
normalized_name = model_name.replace("\\", "/")
|
||||
if normalized_name.startswith("segm/"):
|
||||
return "segment"
|
||||
if normalized_name.startswith("bbox/"):
|
||||
return "detect"
|
||||
|
||||
task = getattr(raw_model, "task", None)
|
||||
if isinstance(task, str) and task:
|
||||
return task
|
||||
return "detect"
|
||||
|
||||
|
||||
def _model_names(raw_model: object) -> dict[int, str]:
|
||||
"""Extract class names from a loaded Ultralytics model."""
|
||||
|
||||
names = getattr(raw_model, "names", {})
|
||||
if isinstance(names, dict):
|
||||
return {int(key): str(value) for key, value in names.items()}
|
||||
if isinstance(names, list):
|
||||
return {index: str(value) for index, value in enumerate(names)}
|
||||
return {}
|
||||
|
||||
|
||||
def _model_device_hint(raw_model: object) -> str:
|
||||
"""Return a best-effort Ultralytics device hint for diagnostics."""
|
||||
|
||||
direct_device = getattr(raw_model, "device", None)
|
||||
if direct_device is not None:
|
||||
return str(direct_device)
|
||||
inner_model = getattr(raw_model, "model", None)
|
||||
inner_device = getattr(inner_model, "device", None)
|
||||
if inner_device is not None:
|
||||
return str(inner_device)
|
||||
return "runtime-owned"
|
||||
@@ -0,0 +1,141 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Load Ultralytics models behind a narrow runtime adapter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from ..shared.logging import get_logger
|
||||
|
||||
LOGGER = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltralyticsDetectorModel:
|
||||
"""Store a loaded Ultralytics detector with SimpleSyrup metadata."""
|
||||
|
||||
model_name: str
|
||||
model_path: Path
|
||||
model: Any
|
||||
task: str
|
||||
names: dict[int, str]
|
||||
supports_segmentation: bool
|
||||
|
||||
|
||||
class UltralyticsModelAdapter:
|
||||
"""Construct detector models through the optional Ultralytics runtime."""
|
||||
|
||||
def __init__(self, ultralytics_module: ModuleType | None = None) -> None:
|
||||
"""Create the adapter with an optional runtime module override."""
|
||||
|
||||
self._ultralytics_module = ultralytics_module
|
||||
|
||||
def load(self, model_name: str, model_path: Path) -> UltralyticsDetectorModel:
|
||||
"""Load one detector checkpoint and expose normalized metadata."""
|
||||
|
||||
model_class = getattr(self._ultralytics(), "YOLO", None)
|
||||
if model_class is None:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires a module exposing the YOLO class."
|
||||
)
|
||||
|
||||
try:
|
||||
raw_model = model_class(str(model_path))
|
||||
except Exception as exc:
|
||||
LOGGER.error(
|
||||
"Failed to load Ultralytics model",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"Ultralytics model '{model_name}' could not be loaded from "
|
||||
f"'{model_path}'."
|
||||
) from exc
|
||||
|
||||
task = _model_task(model_name, raw_model)
|
||||
detector_model = UltralyticsDetectorModel(
|
||||
model_name=model_name,
|
||||
model_path=model_path,
|
||||
model=raw_model,
|
||||
task=task,
|
||||
names=_model_names(raw_model),
|
||||
supports_segmentation=task in {"segment", "segm"},
|
||||
)
|
||||
LOGGER.info(
|
||||
"Ultralytics model loaded",
|
||||
extra={
|
||||
"operation": "load_ultralytics_model",
|
||||
"model_name": model_name,
|
||||
"model_path": str(model_path),
|
||||
"task": task,
|
||||
"device": _model_device_hint(raw_model),
|
||||
},
|
||||
)
|
||||
return detector_model
|
||||
|
||||
def _ultralytics(self) -> ModuleType:
|
||||
"""Import Ultralytics lazily and fail with an actionable message."""
|
||||
|
||||
if self._ultralytics_module is not None:
|
||||
return self._ultralytics_module
|
||||
try:
|
||||
module = importlib.import_module("ultralytics")
|
||||
except ModuleNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
"Ultralytics support requires the 'ultralytics' package in the "
|
||||
"ComfyUI virtual environment."
|
||||
) from exc
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("ultralytics import did not return a module.")
|
||||
self._ultralytics_module = module
|
||||
return module
|
||||
|
||||
|
||||
def _model_task(model_name: str, raw_model: object) -> str:
|
||||
"""Infer detector task from choice prefix or model metadata."""
|
||||
|
||||
normalized_name = model_name.replace("\\", "/")
|
||||
if normalized_name.startswith("segm/"):
|
||||
return "segment"
|
||||
if normalized_name.startswith("bbox/"):
|
||||
return "detect"
|
||||
|
||||
task = getattr(raw_model, "task", None)
|
||||
if isinstance(task, str) and task:
|
||||
return task
|
||||
return "detect"
|
||||
|
||||
|
||||
def _model_names(raw_model: object) -> dict[int, str]:
|
||||
"""Extract class names from a loaded Ultralytics model."""
|
||||
|
||||
names = getattr(raw_model, "names", {})
|
||||
if isinstance(names, dict):
|
||||
return {int(key): str(value) for key, value in names.items()}
|
||||
if isinstance(names, list):
|
||||
return {index: str(value) for index, value in enumerate(names)}
|
||||
return {}
|
||||
|
||||
|
||||
def _model_device_hint(raw_model: object) -> str:
|
||||
"""Return a best-effort Ultralytics device hint for diagnostics."""
|
||||
|
||||
direct_device = getattr(raw_model, "device", None)
|
||||
if direct_device is not None:
|
||||
return str(direct_device)
|
||||
inner_model = getattr(raw_model, "model", None)
|
||||
inner_device = getattr(inner_model, "device", None)
|
||||
if inner_device is not None:
|
||||
return str(inner_device)
|
||||
return "runtime-owned"
|
||||
@@ -0,0 +1,203 @@
|
||||
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
||||
# Copyright (C) 2026 Artificial Sweetener and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""Discover and resolve Ultralytics checkpoints in ComfyUI model folders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import TypeAlias, cast
|
||||
|
||||
from .model_folders import SUPPORTED_MODEL_EXTENSIONS
|
||||
|
||||
ULTRALYTICS_FOLDER = "ultralytics"
|
||||
ULTRALYTICS_BBOX_FOLDER = "ultralytics_bbox"
|
||||
ULTRALYTICS_SEGM_FOLDER = "ultralytics_segm"
|
||||
|
||||
ModelFolderRegistry: TypeAlias = dict[str, tuple[list[str], set[str]]]
|
||||
|
||||
|
||||
class UltralyticsModelFolders:
|
||||
"""Own ComfyUI folder registration, discovery, and safe path resolution."""
|
||||
|
||||
def __init__(self, folder_paths_module: ModuleType | None = None) -> None:
|
||||
"""Create the adapter with an optional ComfyUI module override."""
|
||||
|
||||
self._folder_paths_module = folder_paths_module
|
||||
|
||||
@property
|
||||
def folder_paths_module(self) -> ModuleType | None:
|
||||
"""Return the resolved or injected folder-paths module when available."""
|
||||
|
||||
return self._folder_paths_module
|
||||
|
||||
def available_models(self) -> list[str]:
|
||||
"""Return supported model files in registered Ultralytics folders."""
|
||||
|
||||
self.register()
|
||||
folder_paths = self._folder_paths()
|
||||
choices: set[str] = set()
|
||||
|
||||
for folder in self.paths_for(ULTRALYTICS_FOLDER):
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
choices.update(path.name for path in _supported_files(folder))
|
||||
choices.update(
|
||||
f"bbox/{path.name}" for path in _supported_files(folder / "bbox")
|
||||
)
|
||||
choices.update(
|
||||
f"segm/{path.name}" for path in _supported_files(folder / "segm")
|
||||
)
|
||||
|
||||
for path in self.paths_for(ULTRALYTICS_BBOX_FOLDER):
|
||||
choices.update(f"bbox/{file.name}" for file in _supported_files(path))
|
||||
for path in self.paths_for(ULTRALYTICS_SEGM_FOLDER):
|
||||
choices.update(f"segm/{file.name}" for file in _supported_files(path))
|
||||
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
for folder_name in (
|
||||
ULTRALYTICS_FOLDER,
|
||||
ULTRALYTICS_BBOX_FOLDER,
|
||||
ULTRALYTICS_SEGM_FOLDER,
|
||||
):
|
||||
if folder_name not in registry:
|
||||
continue
|
||||
for filename in folder_paths.get_filename_list(folder_name):
|
||||
path = Path(str(filename))
|
||||
if path.suffix.lower() not in SUPPORTED_MODEL_EXTENSIONS:
|
||||
continue
|
||||
if folder_name == ULTRALYTICS_BBOX_FOLDER:
|
||||
choices.add(f"bbox/{path.name}")
|
||||
elif folder_name == ULTRALYTICS_SEGM_FOLDER:
|
||||
choices.add(f"segm/{path.name}")
|
||||
else:
|
||||
choices.add(path.as_posix())
|
||||
return sorted(choices)
|
||||
|
||||
def resolve(self, model_name: str) -> Path:
|
||||
"""Resolve a safe model choice inside the configured model folders."""
|
||||
|
||||
safe_name = Path(model_name.replace("\\", "/"))
|
||||
if safe_name.is_absolute() or ".." in safe_name.parts:
|
||||
raise ValueError(
|
||||
f"Ultralytics model name '{model_name}' is not a safe relative path."
|
||||
)
|
||||
for candidate in self._candidate_paths(safe_name):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise ValueError(
|
||||
f"Ultralytics model '{model_name}' was not found in configured "
|
||||
"ComfyUI model folders."
|
||||
)
|
||||
|
||||
def register(self) -> None:
|
||||
"""Register conventional Ultralytics folders with ComfyUI when possible."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
add_model_folder_path = getattr(folder_paths, "add_model_folder_path", None)
|
||||
if add_model_folder_path is None:
|
||||
return
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
registrations = (
|
||||
(ULTRALYTICS_FOLDER, models_dir / "ultralytics"),
|
||||
(ULTRALYTICS_BBOX_FOLDER, models_dir / "ultralytics" / "bbox"),
|
||||
(ULTRALYTICS_SEGM_FOLDER, models_dir / "ultralytics" / "segm"),
|
||||
)
|
||||
for folder_name, path in registrations:
|
||||
if folder_name not in registry:
|
||||
add_model_folder_path(folder_name, str(path))
|
||||
|
||||
def paths_for(self, folder_name: str) -> list[Path]:
|
||||
"""Return registered paths for one ComfyUI model folder."""
|
||||
|
||||
folder_paths = self._folder_paths()
|
||||
models_dir = Path(str(folder_paths.models_dir))
|
||||
fallback = {
|
||||
ULTRALYTICS_FOLDER: models_dir / "ultralytics",
|
||||
ULTRALYTICS_BBOX_FOLDER: models_dir / "ultralytics" / "bbox",
|
||||
ULTRALYTICS_SEGM_FOLDER: models_dir / "ultralytics" / "segm",
|
||||
}[folder_name]
|
||||
registry = cast(
|
||||
ModelFolderRegistry,
|
||||
getattr(folder_paths, "folder_names_and_paths", {}),
|
||||
)
|
||||
paths = [fallback]
|
||||
if folder_name in registry:
|
||||
paths = [Path(str(path)) for path in registry[folder_name][0]] + paths
|
||||
return _unique_paths(paths)
|
||||
|
||||
def _candidate_paths(self, model_name: Path) -> list[Path]:
|
||||
"""Return bounded filesystem candidates for a model choice."""
|
||||
|
||||
self.register()
|
||||
parts = model_name.parts
|
||||
if len(parts) >= 2 and parts[0] == "bbox":
|
||||
relative = Path(*parts[1:])
|
||||
return [
|
||||
*(
|
||||
folder / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_BBOX_FOLDER)
|
||||
),
|
||||
*(
|
||||
folder / "bbox" / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_FOLDER)
|
||||
),
|
||||
]
|
||||
if len(parts) >= 2 and parts[0] == "segm":
|
||||
relative = Path(*parts[1:])
|
||||
return [
|
||||
*(
|
||||
folder / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_SEGM_FOLDER)
|
||||
),
|
||||
*(
|
||||
folder / "segm" / relative
|
||||
for folder in self.paths_for(ULTRALYTICS_FOLDER)
|
||||
),
|
||||
]
|
||||
return [folder / model_name for folder in self.paths_for(ULTRALYTICS_FOLDER)]
|
||||
|
||||
def _folder_paths(self) -> ModuleType:
|
||||
"""Import ComfyUI folder path helpers lazily."""
|
||||
|
||||
if self._folder_paths_module is None:
|
||||
module = importlib.import_module("folder_paths")
|
||||
if not isinstance(module, ModuleType):
|
||||
raise TypeError("folder_paths import did not return a module.")
|
||||
self._folder_paths_module = module
|
||||
return self._folder_paths_module
|
||||
|
||||
|
||||
def _supported_files(folder: Path) -> list[Path]:
|
||||
"""Return directly contained supported model files for a folder."""
|
||||
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
path
|
||||
for path in folder.iterdir()
|
||||
if path.is_file() and path.suffix.lower() in SUPPORTED_MODEL_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
def _unique_paths(paths: list[Path]) -> list[Path]:
|
||||
"""Return unique paths while preserving order."""
|
||||
|
||||
unique: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for path in paths:
|
||||
key = str(path)
|
||||
if key not in seen:
|
||||
unique.append(path)
|
||||
seen.add(key)
|
||||
return unique
|
||||
@@ -13,11 +13,12 @@ from typing import Any, Protocol
|
||||
|
||||
import torch
|
||||
|
||||
from ..runtime.anima_artifacts import ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE
|
||||
from ..runtime.anima_artifacts import ANIMA_QWEN_TEXT_ENCODER
|
||||
from ..runtime.auto_model_artifact import AutoModelArtifact
|
||||
from ..runtime.auto_model_resolver import AutoModelResolution, AutoModelResolver
|
||||
from ..runtime.model_downloads import ProgressReporter
|
||||
from ..runtime.quantization_progress import QuantizationProgressReporter
|
||||
from ..runtime.qwen_artifacts import QWEN_IMAGE_VAE
|
||||
from ..runtime.vae_loader import VaeLoaderService, load_vae_path
|
||||
from .anima_diffusion_model_service import AnimaDiffusionModelService
|
||||
|
||||
@@ -125,7 +126,7 @@ class AnimaLoaderService:
|
||||
"""Load a VAE using ComfyUI's VAE loader policy."""
|
||||
|
||||
if vae == AUTO_CHOICE:
|
||||
vae_path = self._resolver.resolve(ANIMA_QWEN_VAE, progress).path
|
||||
vae_path = self._resolver.resolve(QWEN_IMAGE_VAE, progress).path
|
||||
return load_vae_path(vae_path)
|
||||
|
||||
return self._vae_loader.load_vae(vae)
|
||||
|
||||
@@ -26,6 +26,7 @@ from ..runtime.comfy_conditioning_processing import (
|
||||
ComfyRegionalConditioningProcessor,
|
||||
)
|
||||
from ..runtime.comfy_latent_normalization import ComfyLatentNormalizer
|
||||
from ..runtime.global_hook_model_resolver import GlobalHookModelResolver
|
||||
from ..runtime.regional_lora_conditioning_adapter import (
|
||||
RegionalLoraConditioningAdapter,
|
||||
)
|
||||
@@ -82,6 +83,9 @@ class AttentionCouplingModelPreparationService:
|
||||
latent_normalizer_class: ClassVar[type[ComfyLatentNormalizer]] = (
|
||||
ComfyLatentNormalizer
|
||||
)
|
||||
global_hook_model_resolver_class: ClassVar[type[GlobalHookModelResolver]] = (
|
||||
GlobalHookModelResolver
|
||||
)
|
||||
model_family_selector_class: ClassVar[
|
||||
type[AttentionCouplingModelFamilySelector]
|
||||
] = AttentionCouplingModelFamilySelector
|
||||
@@ -103,6 +107,7 @@ class AttentionCouplingModelPreparationService:
|
||||
) -> PreparedAttentionCouplingModel:
|
||||
"""Return one admitted derived model ready for spatial sampling."""
|
||||
|
||||
preparation_negative = positive if negative is None else negative
|
||||
samples = self._latent_samples(latent_image)
|
||||
admission = self.capability_service_class().admit(
|
||||
request=_ATTENTION_REQUEST,
|
||||
@@ -116,6 +121,11 @@ class AttentionCouplingModelPreparationService:
|
||||
)
|
||||
interop_validator = self.interop_validator_class()
|
||||
interop_report = interop_validator.validate(model, capabilities)
|
||||
model = self.global_hook_model_resolver_class().resolve(
|
||||
model,
|
||||
positive=positive,
|
||||
negative=preparation_negative,
|
||||
)
|
||||
model_family = self.model_family_selector_class().select(capabilities)
|
||||
samples = self.latent_normalizer_class().normalize(
|
||||
model=model,
|
||||
@@ -137,7 +147,8 @@ class AttentionCouplingModelPreparationService:
|
||||
return self._prepare_uncached(
|
||||
model=model,
|
||||
positive=positive,
|
||||
negative=negative,
|
||||
negative=preparation_negative,
|
||||
sampling_negative=negative,
|
||||
region_masks=region_masks,
|
||||
regional_prompt_weight=regional_prompt_weight,
|
||||
region_mask_feather=region_mask_feather,
|
||||
@@ -173,6 +184,7 @@ class AttentionCouplingModelPreparationService:
|
||||
model: object,
|
||||
positive: object,
|
||||
negative: object,
|
||||
sampling_negative: object | None,
|
||||
region_masks: object,
|
||||
regional_prompt_weight: float,
|
||||
region_mask_feather: int,
|
||||
@@ -236,7 +248,7 @@ class AttentionCouplingModelPreparationService:
|
||||
return PreparedAttentionCouplingModel(
|
||||
derived_model,
|
||||
sampler_conditioning.positive,
|
||||
sampler_conditioning.negative,
|
||||
(None if sampling_negative is None else sampler_conditioning.negative),
|
||||
mask_bank,
|
||||
)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain import attention_coupling_preparation as preparation_domain
|
||||
from ..domain.raw_regional_attention import (
|
||||
RawRegionalAttentionBranch,
|
||||
RawRegionalAttentionPlan,
|
||||
@@ -31,15 +32,6 @@ _UNSUPPORTED_METADATA_KEYS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AttentionCouplingPreparation:
|
||||
"""Retain the full raw plan and base-only ordinary sampler inputs."""
|
||||
|
||||
plan: RawRegionalAttentionPlan
|
||||
positive: object
|
||||
negative: object
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AttentionCouplingMetadataIssue:
|
||||
"""Identify one unsupported or malformed conditioning metadata field."""
|
||||
@@ -71,7 +63,9 @@ class AttentionCouplingMetadataError(ValueError):
|
||||
class AttentionCouplingPreparationService:
|
||||
"""Separate regional contexts from ordinary KSampler conditioning."""
|
||||
|
||||
def prepare(self, plan: RawRegionalAttentionPlan) -> AttentionCouplingPreparation:
|
||||
def prepare(
|
||||
self, plan: RawRegionalAttentionPlan
|
||||
) -> preparation_domain.AttentionCouplingPreparation:
|
||||
"""Validate all contexts before returning the untouched base conditionings."""
|
||||
|
||||
if not isinstance(plan, RawRegionalAttentionPlan):
|
||||
@@ -82,7 +76,7 @@ class AttentionCouplingPreparationService:
|
||||
)
|
||||
if issues:
|
||||
raise AttentionCouplingMetadataError(issues)
|
||||
return AttentionCouplingPreparation(
|
||||
return preparation_domain.AttentionCouplingPreparation(
|
||||
plan=plan,
|
||||
positive=plan.positive.base_conditioning,
|
||||
negative=plan.negative.base_conditioning,
|
||||
|
||||
@@ -8,8 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from ..domain.segs_mask_ops import feather_mask
|
||||
from ..masking.mask_components import connected_mask_components
|
||||
from ..masking.segs_mask_ops import feather_mask
|
||||
|
||||
|
||||
class AttentionMatteService:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user