Compare commits

..
5 Commits
Author SHA1 Message Date
Daisy e7ff15d5a7 chore(release): 1.10.1 [skip ci]
## [1.10.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.0...v1.10.1) (2026-09-24)

### Bug Fixes

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

### Features

* **loaders:** add Krea 2 model loader ([166f029](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/166f029d12e5fce019133cc38be7279d05a5ecbc))
2026-09-21 04:23:59 +00:00
Artificial Sweetener 6184b74809 feat(loaders): add Krea 2 model loader
Add automatic FP8/BF16 Qwen encoder and shared VAE resolution, architecture validation, artifact-aware dropdown deduplication, and renamed artifact discovery across supported loaders.
2026-09-21 00:17:31 -04:00
864 changed files with 17267 additions and 8949 deletions
+62
View File
@@ -0,0 +1,62 @@
name: quality gates
on:
pull_request:
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
env:
SIMPLE_SYRUP_TEST_COMFY_CPU: "1"
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22.14.0
cache: npm
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Node dependencies
run: npm ci
- name: Install ComfyUI host dependencies
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/comfyanonymous/ComfyUI.git "$RUNNER_TEMP/ComfyUI"
rsync -a --exclude=".git" "$RUNNER_TEMP/ComfyUI/" "$GITHUB_WORKSPACE/../.."/
pip install -r "$GITHUB_WORKSPACE/../../requirements.txt"
- name: Install Python dependencies
run: pip install -e . pytest pytest-xdist ruff mypy
- name: Check architecture governance
run: python -m tools.check_architecture
- name: Check test governance
run: python -m tools.check_test_governance
- name: Verify Python formatting
run: ruff format --check .
- name: Verify Python lint
run: ruff check .
- name: Verify Python types
run: mypy --strict simple_syrup tests
- name: Verify Python tests
run: pytest -n auto -q -m "not external_artifact"
- name: Verify frontend
run: npm run check:web
+10 -2
View File
@@ -50,13 +50,15 @@ jobs:
"transformers${{ matrix.version }}"
- name: Verify GroundingDINO BERT compatibility
env:
PYTHONPATH: ${{ github.workspace }}/tests
run: >-
python -m pytest -q
--noconftest
--rootdir=tests
--confcutdir=tests
tests/test_grounding_dino_bert_adapter.py
tests/test_grounding_dino_text_token_masks.py
tests/segmentation/detection/test_grounding_dino_bert_adapter.py
tests/segmentation/detection/test_grounding_dino_text_token_masks.py
release:
if: github.event_name != 'pull_request'
@@ -98,6 +100,12 @@ jobs:
- name: Verify Python formatting
run: ruff format --check .
- name: Check architecture governance
run: python -m tools.check_architecture
- name: Check test governance
run: python -m tools.check_test_governance
- name: Verify Python lint
run: ruff check .
+29
View File
@@ -0,0 +1,29 @@
repos:
- repo: local
hooks:
- id: architecture-governance
name: Enforce architecture governance
entry: ..\..\venv\Scripts\python.exe -m tools.check_architecture
language: system
pass_filenames: false
always_run: true
- id: test-governance
name: Enforce test governance
entry: ..\..\venv\Scripts\python.exe -m tools.check_test_governance
language: system
pass_filenames: false
always_run: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=lf]
exclude: '(\.bat$|\.cmd$|\.ps1$)'
- id: trailing-whitespace
- id: check-merge-conflict
- id: check-yaml
- id: check-json
exclude: '(^web/dist/|tsconfig\.json$)'
- id: check-toml
+30
View File
@@ -35,6 +35,8 @@ Engineering priority is strict architecture, strong separation of concerns, comp
### Required Command Forms
- Tests: `..\..\venv\Scripts\python.exe -m pytest -n auto -q`
- Architecture: `..\..\venv\Scripts\python.exe -m tools.check_architecture`
- Test governance: `..\..\venv\Scripts\python.exe -m tools.check_test_governance`
- Lint: `..\..\venv\Scripts\ruff.exe check .`
- Format: `..\..\venv\Scripts\ruff.exe format .`
- Type check: `..\..\venv\Scripts\mypy.exe --strict simple_syrup tests`
@@ -94,6 +96,34 @@ If a required tool is missing from `..\..\venv`, install or update development d
- Reorganize modules when it improves architecture.
- Align touched modules with the ownership and dependency rules in this file.
## Architecture Governance
- Repository governance lives under `governance/`.
- `governance/architecture/policy.toml` defines every authored-code root,
extension, exclusion, and the 350-line soft and 500-line hard structural
thresholds.
- `governance/architecture/debt.toml` records exact assessed mixed ownership.
- `governance/architecture/waivers.toml` records exact bounded hard-gate
exceptions.
- `governance/architecture/import_debt.toml` records exact current dependency-
direction violations; new violations are prohibited.
- `governance/architecture/soft_reviews.toml` records the current human
disposition of every file between the soft and hard thresholds.
- Every hard-gate file requires source-level ownership review.
- Use a structural waiver only for one cohesive authoritative owner whose
invariants would be divided by extraction.
- Mixed ownership requires debt and a linked remediation waiver naming the
next extraction and a lower next limit.
- Waivers and debt are fingerprinted current state, not historical ledgers.
- Delete resolved records; do not extend dates or limits merely to pass the
checker.
- `governance/testing/policy.toml` defines Python and frontend test-layout and
reliability discovery.
- Every test-governance candidate requires an exact classification or
debt-remediation disposition.
- Run both governance checkers after changing authored structure, test
placement, isolation, timing, resources, or reviewed state.
## ComfyUI Node Rules
- Public node identifiers are compatibility-sensitive.
+15
View File
@@ -1,3 +1,18 @@
## [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)
+5 -3
View File
@@ -12,7 +12,7 @@ The pack now covers model loading, regional prompting and segmentation, high-res
## Highlights
- Loaders that keep checkpoints, Anima, FLUX.1, and FLUX.2 models together with the text encoders, VAE, precision, and device choices they need.
- Loaders that keep checkpoints, Anima, FLUX.1, FLUX.2, and Krea 2 models together with the text encoders, VAE, precision, and device choices they need.
- My original Contextual Diffusion method for coherent high-resolution edits, plus MultiDiffusion and Mixture of Diffusers tiled sampling.
- Impact-compatible SEGS detection, segmentation, interactive preview, batching, and detailers.
- ADetailer-style `[SEP]` prompt batches, masked conditioning, and regional samplers, with optional Prompt Control scheduling and LoRA hooks.
@@ -74,7 +74,9 @@ Loading a checkpoint used to feel like choosing one file. Newer model families c
**Simple Load FLUX** handles FLUX.1 with CLIP-L, T5-XXL, and its VAE. **Simple Load FLUX.2** inspects the selected diffusion model and chooses the matching text encoder family for FLUX.2 dev, Klein 4B, or Klein 9B/KV conditioning. Both loaders can find or download their known text encoders and VAEs with visible Comfy progress.
The FLUX loaders only download those revision-locked, checksum-pinned support files. You still install and select the diffusion model. They also expose manual component selection, diffusion weight precision, and text-encoder device placement. Moving text encoding to the CPU can save VRAM, although it will take longer.
**Simple Load Krea 2** validates the selected Raw or Turbo diffusion model and loads the required Qwen3-VL 4B encoder with Krea's layered conditioning plus the Qwen Image VAE. Auto uses the official FP8-scaled encoder; the advanced encoder choice can download either the checksum-pinned FP8-scaled or BF16 file.
The FLUX and Krea 2 loaders only download those revision-locked, checksum-pinned support files. You still install and select the diffusion model. They also expose manual component selection, diffusion weight precision, and text-encoder device placement. Moving text encoding to the CPU can save VRAM, although it will take longer.
## Large images and high-resolution edits
@@ -164,7 +166,7 @@ SimpleSyrup adds three ComfyUI settings:
- **SimpleSyrup: External LLM endpoint** stores the OpenAI-compatible base URL used to discover provider models and run the external prompt nodes.
- **SimpleSyrup: External LLM API key** stores the provider key in OS credential storage.
With downloadable models enabled, selecting a known missing catalog entry lets its loader download the required files. With the setting disabled, the dropdowns contain models SimpleSyrup can verify locally. Anima, FLUX.1, and FLUX.2 support components are resolved by their own loaders and use checksum-pinned automatic choices.
With downloadable models enabled, selecting a known missing catalog entry lets its loader download the required files. With the setting disabled, the dropdowns contain models SimpleSyrup can verify locally. Anima, FLUX.1, FLUX.2, and Krea 2 support components are resolved by their own loaders and use checksum-pinned automatic choices. Automatic resolution checks cached and official paths first, then recognizes renamed files with matching size and checksum inside the appropriate ComfyUI model category. Known local support files are represented by their automatic choice instead of appearing again as manual dropdown entries.
Saving the external LLM endpoint and API key refreshes the provider models available in connected SimpleSyrup nodes. Image inputs require a provider model with vision support.
+12 -10
View File
@@ -12,22 +12,24 @@ from . import simple_syrup as _simple_syrup_package
sys.modules.setdefault("simple_syrup", _simple_syrup_package)
from .simple_syrup.integration.external_llm_routes import ( # noqa: E402
register_external_llm_routes,
)
from .simple_syrup.integration.mask_batch_preview_routes import ( # noqa: E402
register_mask_batch_preview_routes,
)
from .simple_syrup.integration.quant_cache_routes import ( # noqa: E402
register_quant_cache_routes,
)
from .simple_syrup.integration.settings_routes import ( # noqa: E402
register_settings_routes,
)
from .simple_syrup.runtime.attention_region_prompt_handler import ( # noqa: E402
register_attention_region_prompt_handler,
)
from .simple_syrup.runtime.comfy_safetensors_dtypes import ( # noqa: E402
register_comfy_safetensors_dtypes,
)
from .simple_syrup.runtime.external_llm_routes import ( # noqa: E402
register_external_llm_routes,
)
from .simple_syrup.runtime.mask_batch_preview_routes import ( # noqa: E402
register_mask_batch_preview_routes,
)
from .simple_syrup.runtime.quant_cache_routes import ( # noqa: E402
register_quant_cache_routes,
)
from .simple_syrup.runtime.settings_routes import register_settings_routes # noqa: E402
WEB_DIRECTORY = "./web/dist"
+2
View File
@@ -0,0 +1,2 @@
schema_version = 1
debts = []
+1
View File
@@ -0,0 +1 @@
schema_version = 1
+41
View File
@@ -0,0 +1,41 @@
schema_version = 2
[structure]
soft_lines = 350
hard_lines = 500
source_roots = [
"simple_syrup/domain",
"simple_syrup/image",
"simple_syrup/integration",
"simple_syrup/masking",
"simple_syrup/nodes",
"simple_syrup/nodes_v3",
"simple_syrup/runtime",
"simple_syrup/services",
"simple_syrup/shared",
"tests",
"tools",
"scripts",
"web/src",
"web/tests",
]
source_files = [
"__init__.py",
".releaserc.cjs",
"eslint.config.js",
"simple_syrup/__init__.py",
"vitest.config.ts",
]
source_extensions = [
".cjs",
".js",
".mjs",
".py",
".pyi",
".ts",
]
excluded_paths = []
[registries]
debt = "governance/architecture/debt.toml"
waivers = "governance/architecture/waivers.toml"
+59
View File
@@ -0,0 +1,59 @@
schema_version = 1
review_by = 2027-03-31
fingerprint = "sha256:0a365aa7572ed38582a6fb4f09acefe700212ba65860e93e8c7cd8d5d7d12c45"
cohesive_paths = [
"simple_syrup/masking/prompt_segs_with_sam_service.py",
"simple_syrup/nodes/prompt_segs_with_sam.py",
"simple_syrup/nodes_v3/legacy_node_wrappers.py",
"simple_syrup/runtime/attention_region_affinity.py",
"simple_syrup/runtime/attention_region_capture.py",
"simple_syrup/runtime/attention_sampler_lineage.py",
"simple_syrup/runtime/regional_lora/anima_module_surface.py",
"simple_syrup/runtime/spatial_model_arguments.py",
"simple_syrup/services/concept_attention_evidence.py",
"tests/comfy_integration/test_comfy_regional_adapter_resolver.py",
"tests/comfy_integration/test_comfy_regional_conditioning_processing.py",
"tests/models/loading/test_checkpoint_quantizer.py",
"tests/models/patching/test_model_patcher_mutations.py",
"tests/prompting/prompt_control/test_prompt_control_schedule_encode_graph.py",
"tests/regional_generation/anima/test_anima_activation_context.py",
"tests/regional_generation/anima/test_anima_full_tile_lora_equivalence.py",
"tests/regional_generation/anima/test_anima_loader.py",
"tests/regional_generation/anima/test_anima_multi_lora_composition.py",
"tests/regional_generation/anima/test_anima_regional_diagnostics.py",
"tests/regional_generation/anima/test_anima_regional_diagnostics_wrapper.py",
"tests/regional_generation/anima/test_anima_regional_permutation_diagnostics.py",
"tests/regional_generation/anima/test_anima_single_adapter_mutations.py",
"tests/regional_generation/attention_coupling/test_attention_coupling_model_preparation_service.py",
"tests/regional_generation/attention_regions/test_attention_region_capture.py",
"tests/regional_generation/attention_regions/test_attention_region_completion.py",
"tests/regional_generation/attention_regions/test_attention_region_components.py",
"tests/regional_generation/attention_regions/test_attention_region_geometry.py",
"tests/regional_generation/regional/test_regional_attention_batching.py",
"tests/regional_generation/regional/test_regional_linear_execution.py",
"tests/regional_generation/regional/test_regional_model_patch_interop.py",
"tests/regional_generation/regional/test_regional_multidiffusion_sampling.py",
"tests/regional_generation/spatial/test_contextual_model_wrapper.py",
"tests/sampling/test_multidiffusion_sampling.py",
"tests/sampling/test_sampling_scheduler_references.py",
"tests/sampling/test_sampling_schedulers.py",
"tests/segmentation/detection/test_ultralytics_loader.py",
"tests/segmentation/segs/test_detail_segs_as_regions_service.py",
"tests/segmentation/segs/test_prompt_segs_with_sam_node.py",
"tools/architecture_governance/validation.py",
"tools/attention_coupling_benchmark/comfy_probe/negpip_runtime.py",
"tools/negpip_integration/run.py",
"tools/prompt_control_attention_coupling_integration/validation.py",
"tools/run_global_prompt_lora_proof.py",
"tools/test_governance/semantic_patterns.py",
"tools/test_governance/validation.py",
"web/src/orderedMediaNode.ts",
"web/src/orderedMediaPreviewActions.ts",
"web/tests/media/orderedMediaPreviewActions.test.ts",
]
debt_paths = [
]
remediations = []
+78
View File
@@ -0,0 +1,78 @@
schema_version = 1
[[waivers]]
id = "SSY-WAIVER-S001"
owner = "model catalog"
rule = "STRUCT003"
path = "simple_syrup/runtime/model_catalog.py"
kind = "structural"
justification = "This module is the single immutable catalog authority for supported model families and artifacts. Most of its size is declarative checksums, repository identities, filenames, and URLs; its small query surface and entry constructors enforce one catalog schema and change with that same metadata contract. Splitting entries by provider would scatter uniqueness and lookup review without separating behavior or ownership."
issue = "chore:SSY-WAIVER-S001"
review_by = 2027-03-31
max_lines = 687
[[waivers]]
id = "SSY-WAIVER-S002"
owner = "sampling scheduler policy"
rule = "STRUCT003"
path = "simple_syrup/runtime/sampling_schedulers.py"
kind = "structural"
justification = "This module owns the complete sigma-schedule policy exposed to every sampler: supported names, fixed published AYS/GITS tables, Comfy delegation, local schedule calculation, denoise truncation, and sampler-specific terminal handling. Roughly half the file is immutable numeric reference data, while the executable functions share one public calculation boundary and dependency direction."
issue = "chore:SSY-WAIVER-S002"
review_by = 2027-03-31
max_lines = 588
[[waivers]]
id = "SSY-WAIVER-S003"
owner = "Anima cross-attention patch contracts"
rule = "STRUCT003"
path = "tests/regional_generation/anima/test_anima_cross_attention.py"
kind = "structural"
justification = "This module is one integration contract for AnimaRegionalCrossAttentionPatch: it installs the exact Anima module surface, supplies one deterministic attention double, drives branch/mask/context alignment, verifies failure restoration, and proves all 28 clone-local patches. The sizable builders encode a single valid execution context and are not independent production responsibilities."
issue = "chore:SSY-WAIVER-S003"
review_by = 2027-03-31
max_lines = 661
[[waivers]]
id = "SSY-WAIVER-S004"
owner = "Anima multi-LoRA fidelity contracts"
rule = "STRUCT003"
path = "tests/regional_generation/anima/test_anima_multi_lora_fidelity.py"
kind = "structural"
justification = "This module owns one numerical fidelity matrix for ordered multi-LoRA composition across schedules, branches, regions, target families, and the complete Anima surface. Its execution and reference helpers intentionally remain adjacent so every permutation is compared through the same independently calculated oracle; splitting by scenario would duplicate or conceal that shared proof authority."
issue = "chore:SSY-WAIVER-S004"
review_by = 2027-03-31
max_lines = 678
[[waivers]]
id = "SSY-WAIVER-S005"
owner = "regional convolution execution contracts"
rule = "STRUCT003"
path = "tests/regional_generation/regional/test_regional_convolution_execution.py"
kind = "structural"
justification = "This module is the complete numerical contract for RegionalConvolutionExecutor across direct, pointwise, LoCon, strided, grouped, tiled-batch, ordered-adapter, and low-precision execution. Its fixture builds the same execution plan and independent convolution reference for every case, so the tests share one owner, oracle, dependency surface, and change cadence."
issue = "chore:SSY-WAIVER-S005"
review_by = 2027-03-31
max_lines = 556
[[waivers]]
id = "SSY-WAIVER-S006"
owner = "Ultralytics detection node contracts"
rule = "STRUCT003"
path = "tests/segmentation/detection/test_detect_segs_with_ultralytics_node.py"
kind = "structural"
justification = "This module owns the workflow-facing contract of one Comfy node, including its schema, exact input order, batch behavior, sorting/ranking limits, union mode, and output shape. The service and builder doubles are deliberately local representations of that node boundary; every test changes with the same node API and persisted workflow contract."
issue = "chore:SSY-WAIVER-S006"
review_by = 2027-03-31
max_lines = 592
[[waivers]]
id = "SSY-WAIVER-S007"
owner = "scale-factor detail service contracts"
rule = "STRUCT003"
path = "tests/segmentation/segs/test_detail_segs_by_scale_factor_service.py"
kind = "structural"
justification = "This module is the end-to-end behavioral contract for DetailSEGSByScaleFactorService, whose single orchestration transaction selects per-segment conditioning, sizes and resizes crops, applies masks, samples, decodes, and pastes results. Its sampler and resizer doubles record that one transaction; splitting them would duplicate setup without creating a distinct behavior owner."
issue = "chore:SSY-WAIVER-S007"
review_by = 2027-03-31
max_lines = 532
+2
View File
@@ -0,0 +1,2 @@
schema_version = 1
debts = []
+27
View File
@@ -0,0 +1,27 @@
schema_version = 1
[scope]
test_root = "tests"
semantic_support_roots = ["tools"]
root_source_extensions = [".py", ".pyi"]
allowed_root_source_paths = [
"tests/ci_test_policy.py",
"tests/conftest.py",
]
[discovery]
serial_policy = "tests/ci_test_policy.py"
wait_calls = ["QTest.qWait", "time.sleep"]
wall_clock_calls = [
"QElapsedTimer",
"monotonic",
"perf_counter",
"time.monotonic",
"time.perf_counter",
]
xdist_environment_name = "PYTEST_XDIST_WORKER"
repository_scratch_name = ".pytest-tmp"
[registries]
debt = "governance/testing/debt.toml"
waivers = "governance/testing/waivers.toml"
+153
View File
@@ -0,0 +1,153 @@
schema_version = 1
[[waivers]]
id = "SSY-TEST-WAIVER-C001"
owner = "pytest CUDA isolation bootstrap"
kind = "classification"
disposition = "framework_infrastructure"
rule = "ENV001"
candidates = ["ENV001|tests/conftest.py|<module>:environment-mutation:1"]
paths = ["tests/conftest.py"]
fingerprint = "sha256:481069f16237eff312f817f1e4dd3213e804eee0f3341e3f6c4aa2ba73066af3"
rationale = "The root pytest bootstrap disables CUDA visibility before Torch and ComfyUI are imported unless the maintainer explicitly enables hardware tests. Every xdist worker receives the same inherited setting before collection, so this is suite framework configuration rather than mutable test-owned state."
issue = "chore:SSY-TEST-WAIVER-C001"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C002"
owner = "native checkpoint quantization proofs"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = [
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:1",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:2",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:3",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:4",
"OPTIONAL001|tests/models/loading/test_checkpoint_quantizer.py|<module>:optional-proof:5",
]
paths = ["tests/models/loading/test_checkpoint_quantizer.py"]
fingerprint = "sha256:bc2bee06ddb6cf41ba66497855e7349e5aa5dd5292f732284a812a615fa65c49"
rationale = "These proofs exercise installed ComfyUI NVFP4/MXFP8 kernels, GPU compute capability, and optional comfy-aimdo reload behavior. CPU fake-boundary tests in the same module always run; only the native serialization contracts are skipped when their external runtime or hardware capability does not exist."
issue = "chore:SSY-TEST-WAIVER-C002"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C003"
owner = "CUDA Anima projection precision proof"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_projection_batch.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/anima/test_anima_projection_batch.py"]
fingerprint = "sha256:cb5e280524450a58144b350799129b3c27a6d4ce430867de582da19303987c33"
rationale = "This exact comparison bounds BF16 batched projection error on CUDA tensors against independently generated projections. Its behavior depends on the installed CUDA execution path and cannot truthfully be substituted by CPU arithmetic; all device-independent projection contracts remain mandatory."
issue = "chore:SSY-TEST-WAIVER-C003"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C004"
owner = "native Anima quantization workflow"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_quantization_workflow.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/anima/test_anima_quantization_workflow.py"]
fingerprint = "sha256:ff02416ea8c16112605b221975425a6f88013f66e2a8bc66bdc2d1a0ecfa5053"
rationale = "The workflow proof intentionally uses the installed ComfyUI NVFP4 implementation and the active GPU's native compute support before loading the generated Anima artifact. It remains optional only where that hardware capability is absent; the portable resolver and policy tests still run everywhere."
issue = "chore:SSY-TEST-WAIVER-C004"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C005"
owner = "installed Anima CUDA smoke"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/anima/test_anima_regional_model_smoke.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/anima/test_anima_regional_model_smoke.py"]
fingerprint = "sha256:73b3bde9d6009b71608aec73dde862d963a3a723d5ad5d57c130428a4c1511c6"
rationale = "This smoke test constructs the installed Comfy Anima model and executes its complete patched forward on CUDA tensors. It proves the native device/runtime integration and is skipped only without CUDA; deterministic component and surface contracts cover the same code boundaries on every host."
issue = "chore:SSY-TEST-WAIVER-C005"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C006"
owner = "regional convolution CUDA precision"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_convolution_execution.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/regional/test_regional_convolution_execution.py"]
fingerprint = "sha256:f197de881034f1c11b46ce290f3b6c515fe251d5bdef7efb419f43ad7257bf40"
rationale = "The optional parameterized cases prove FP16 and BF16 regional convolution behavior through the installed CUDA kernels. CPU tests in the same contract cover dimensions, grouping, stride, masking, ordering, and reference math; only device-specific low-precision execution requires CUDA."
issue = "chore:SSY-TEST-WAIVER-C006"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C007"
owner = "regional linear CUDA precision"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = [
"OPTIONAL001|tests/regional_generation/regional/test_regional_linear_execution.py|<module>:optional-proof:1",
"OPTIONAL001|tests/regional_generation/regional/test_regional_linear_execution.py|<module>:optional-proof:2",
]
paths = ["tests/regional_generation/regional/test_regional_linear_execution.py"]
fingerprint = "sha256:d8a833ef160838b80db21d7240d789879deb8a4dc39f89d52145b1ebf580f765"
rationale = "These cases validate installed CUDA FP16/BF16 projection rounding and compatible-adapter accumulation on the actual device execution path. The module's CPU contracts always prove masking, ordering, preparation, and reference deltas; the classified cases add hardware-specific numerical evidence."
issue = "chore:SSY-TEST-WAIVER-C007"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C008"
owner = "fused regional LoRA CUDA kernel"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_lora_fused_active_accumulation.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/regional/test_regional_lora_fused_active_accumulation.py"]
fingerprint = "sha256:2053ff281c4e707c99db6424074ef3526c901ad84f0f750e044c35116981cd9c"
rationale = "The entire module qualifies the CUDA-only fused regional LoRA accumulator across low-precision dtypes, adapter counts, and indexed paths. There is no CPU implementation to exercise, while the non-fused accumulation owner has mandatory portable reference coverage."
issue = "chore:SSY-TEST-WAIVER-C008"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C009"
owner = "fused multiplier CUDA transport"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/regional_generation/regional/test_regional_lora_fused_multiplier_transport.py|<module>:optional-proof:1"]
paths = ["tests/regional_generation/regional/test_regional_lora_fused_multiplier_transport.py"]
fingerprint = "sha256:56ef4217551bdccc97fc6277bd2ade11ac10b9512ee3725c46b210044f380f21"
rationale = "This module proves multiple adapter multipliers reach the CUDA fused kernel without an intermediate stack. The production behavior exists only for a CUDA-capable device, and portable composition tests independently cover ordering and multiplier semantics outside this native optimization."
issue = "chore:SSY-TEST-WAIVER-C009"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C010"
owner = "ordered tensor CUDA accumulation"
kind = "classification"
disposition = "platform_native"
rule = "OPTIONAL001"
candidates = ["OPTIONAL001|tests/sampling/test_ordered_tensor_accumulation.py|<module>:optional-proof:1"]
paths = ["tests/sampling/test_ordered_tensor_accumulation.py"]
fingerprint = "sha256:b6306323fd555706f0b7525078acfa199c657f47f96432ce320da9057fc41709"
rationale = "The parameter matrix compares stepwise CUDA accumulation and its exact low-precision rounding across one and multiple Triton launches. Mandatory CPU contracts prove ordered in-place accumulation; only the GPU kernel and device dtypes are capability-gated."
issue = "chore:SSY-TEST-WAIVER-C010"
review_by = 2027-03-31
[[waivers]]
id = "SSY-TEST-WAIVER-C011"
owner = "managed Windows Comfy process lifetime"
kind = "classification"
disposition = "platform_native"
rule = "PROCESS001"
candidates = ["PROCESS001|tools/comfy_integration/server_process.py|<module>:unscoped-child-process:1"]
paths = ["tools/comfy_integration/server_process.py"]
fingerprint = "sha256:6892815fbffe96d59bbb3f9069e44bfcdb9d6ea39e3f17f844695408af4233c0"
rationale = "WindowsComfyProcess intentionally transfers the created Popen and log handles into an explicit long-lived owner because the integration run must use the server after start returns. Its stop method signals the exact process group, bounds both graceful and forced waits, retries bounded taskkill calls, and closes both logs in finally."
issue = "chore:SSY-TEST-WAIVER-C011"
review_by = 2027-03-31
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "simple-syrup-comfyui",
"version": "1.9.3",
"version": "1.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simple-syrup-comfyui",
"version": "1.9.3",
"version": "1.10.1",
"license": "AGPL-3.0-or-later",
"devDependencies": {
"@eslint/js": "^9.39.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "simple-syrup-comfyui",
"version": "1.9.3",
"version": "1.10.1",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+2 -1
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "SimpleSyrup"
description = "Workflow-focused ComfyUI extensions for image generation."
version = "1.9.3"
version = "1.10.1"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
requires-python = ">=3.11"
@@ -69,6 +69,7 @@ ignore_missing_imports = true
[tool.pytest.ini_options]
pythonpath = [".", "../.."]
testpaths = ["tests"]
addopts = ["--strict-markers", "--import-mode=importlib"]
markers = [
"external_artifact: requires a locally installed external source or generated benchmark artifact",
]
+1 -1
View File
@@ -6,6 +6,6 @@
from __future__ import annotations
__version__ = "1.9.3"
__version__ = "1.10.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
+51
View File
@@ -0,0 +1,51 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define renderer-neutral SEG preview documents."""
from __future__ import annotations
from dataclasses import dataclass
import torch
from .segs import CropRegion
@dataclass(frozen=True)
class AtlasPlacement:
"""Locate one region mask inside the packed mask atlas."""
left: int
top: int
width: int
height: int
@dataclass(frozen=True)
class SegPreviewRegion:
"""Describe one interactive region and its packed mask geometry."""
region_id: str
index: int
label: str
confidence: float
active_area: int
color: str
crop: CropRegion
atlas: AtlasPlacement
@dataclass(frozen=True)
class SegPreviewDocument:
"""Carry bounded image assets and interaction metadata to a UI adapter."""
source_width: int
source_height: int
preview_width: int
preview_height: int
image: torch.Tensor
atlas: torch.Tensor
region_images: tuple[torch.Tensor, ...]
regions: tuple[SegPreviewRegion, ...]
@@ -9,7 +9,7 @@ from __future__ import annotations
import torch
import torch.nn.functional as F
from ..domain.segs import BoundingBox, CropRegion
from .segs import BoundingBox, CropRegion
def validate_single_image(image: object, operation: str) -> torch.Tensor:
+5 -5
View File
@@ -11,11 +11,6 @@ from dataclasses import dataclass
import torch
from ..masking.segs_mask_ops import (
crop_region_for_bbox,
resize_mask,
validate_single_image,
)
from ..shared.logging import get_logger
from .segs import (
BoundingBox,
@@ -23,6 +18,11 @@ from .segs import (
NativeSegs,
Segment,
)
from .segs_mask_ops import (
crop_region_for_bbox,
resize_mask,
validate_single_image,
)
LOGGER = get_logger(__name__)
+5
View File
@@ -0,0 +1,5 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own inbound ComfyUI integration and transport composition."""
@@ -14,9 +14,9 @@ from typing import Any, Protocol, cast
from aiohttp import web
from ..domain.external_llm import ExternalLLMConfigError, ExternalLLMProviderError
from ..runtime.external_llm_keyring import ExternalLLMKeyringError
from ..services.external_llm_prompt_service import ExternalLLMPromptService
from ..shared.logging import get_logger
from .external_llm_keyring import ExternalLLMKeyringError
LOGGER = get_logger(__name__)
EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"
@@ -12,13 +12,13 @@ from typing import Any, Protocol, cast
from aiohttp import web
from ..runtime.quant_cache_settings import SettingsQuantCacheLimitProvider
from ..services.quant_cache_service import (
QuantCacheEvictionResult,
QuantCacheService,
QuantCacheStatus,
)
from ..services.quantized_model_boundaries import QuantCacheLimitProvider
from .quant_cache_settings import SettingsQuantCacheLimitProvider
QUANT_CACHE_ROUTE = "/simple-syrup/quant-cache"
Handler = Callable[[Any], Coroutine[Any, Any, web.Response]]
@@ -12,12 +12,12 @@ from typing import Any, Protocol, cast
from aiohttp import web
from ..shared.logging import get_logger
from .settings import (
from ..runtime.settings import (
SimpleSyrupSettings,
SimpleSyrupSettingsError,
)
from .settings_repository import SimpleSyrupSettingsRepository
from ..runtime.settings_repository import SimpleSyrupSettingsRepository
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
SETTINGS_ROUTE = "/simple-syrup/settings"
@@ -15,8 +15,7 @@ from ..domain.segs import (
NativeSegs,
Segment,
)
from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask
from ..masking.segs_mask_ops import (
from ..domain.segs_mask_ops import (
crop_image,
crop_mask,
crop_region_for_bbox,
@@ -24,6 +23,7 @@ from ..masking.segs_mask_ops import (
normalize_mask,
validate_single_image,
)
from ..masking.mask_ops import MaskRefinementSettings, refine_prompt_mask
from ..runtime.sam_segmenter import SAMBoxSegmenter, SAMModelSegmenter
from ..runtime.text_box_detector import (
GroundingDINOTextBoxDetector,
@@ -21,8 +21,8 @@ from ..domain.regional_detailing import (
SegmentConditioningPair,
)
from ..domain.segs import CropRegion
from ..domain.segs_mask_ops import feather_mask, resize_mask
from .detailer_masks import gaussian_feather_mask
from .segs_mask_ops import feather_mask, resize_mask
OPERATION = "Detail SEGS as Regions"
@@ -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,
)
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -16,9 +16,9 @@ from ..domain.segs import (
SORT_ORDER_OPTIONS,
NativeSegs,
)
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
from ..masking.mask_ops import DETAIL_METHODS
from ..masking.prompt_segs_with_sam_service import PromptSEGSWithSAMService
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..services.segs_output_service import (
CombinedSegsResult,
build_combined_segs_result,
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any
from ..runtime.prompt_control_schedule_encode_graph import (
from ..services.prompt_control_schedule_encode_graph import (
PromptControlScheduleEncodeGraphBuilder,
)
+1 -1
View File
@@ -11,7 +11,7 @@ from typing import Any, ClassVar
import torch
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
from ..runtime.progress import PhaseProgressReporter, create_comfy_phase_progress
from ..services.segs_from_sam_output_service import SEGSFromSAMOutputService
+15 -8
View File
@@ -11,10 +11,13 @@ from types import ModuleType
from typing import Any
from ..domain.anima_quantization import AnimaQuantizationRecipe
from ..runtime.anima_artifacts import ANIMA_QWEN_TEXT_ENCODER
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.quantization_capabilities import QuantizationCapabilityCatalog
from ..runtime.quantization_progress import ComfyQuantizationProgressReporter
from ..runtime.qwen_artifacts import QWEN_IMAGE_VAE
from ..runtime.vae_loader import vae_choices
from ..services.anima_loader_service import (
AUTO_CHOICE,
@@ -84,7 +87,12 @@ class SimpleLoadAnima:
},
),
"text_encoder": (
_choices_with_auto(folder_paths.get_filename_list("text_encoders")),
automatic_component_choices(
installed=folder_paths.get_filename_list("text_encoders"),
artifacts=(ANIMA_QWEN_TEXT_ENCODER,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
{
"default": AUTO_CHOICE,
"advanced": True,
@@ -106,7 +114,12 @@ class SimpleLoadAnima:
},
),
"vae": (
_choices_with_auto(vae_choices(folder_paths)),
automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(QWEN_IMAGE_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
{
"default": AUTO_CHOICE,
"advanced": True,
@@ -142,12 +155,6 @@ class SimpleLoadAnima:
)
def _choices_with_auto(choices: list[str]) -> list[str]:
"""Return choices with the automatic selection first and deduplicated."""
return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)]
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily."""
+2
View File
@@ -67,6 +67,7 @@ def get_nodes() -> list[type[object]]:
from .simple_load_checkpoint import SimpleLoadCheckpointV3
from .simple_load_flux import SimpleLoadFluxV3
from .simple_load_flux2 import SimpleLoadFlux2V3
from .simple_load_krea2 import SimpleLoadKrea2V3
from .tag_segs_with_external_llm import TagSEGSWithExternalLLMV3
from .tag_segs_with_wd14 import TagSEGSWithWD14V3
from .tile_and_tag_segs import TileAndTagSEGSV3
@@ -121,6 +122,7 @@ def get_nodes() -> list[type[object]]:
SimpleLoadCheckpointV3,
SimpleLoadFluxV3,
SimpleLoadFlux2V3,
SimpleLoadKrea2V3,
SimpleVAEEncodeV3,
TagSEGSWithExternalLLMV3,
TagSEGSWithWD14V3,
@@ -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:
+1 -1
View File
@@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, Any, ClassVar
import torch
from ..domain.segs import SORT_ORDER_OPTIONS, NativeSegs
from ..masking.segs_mask_ops import iter_single_images, validate_image_batch
from ..domain.segs_mask_ops import iter_single_images, validate_image_batch
from ..services.mask_to_segs_service import MaskToSEGSService
from ..services.segs_output_service import (
CombinedSegsResult,
@@ -9,7 +9,7 @@ from __future__ import annotations
from importlib import import_module
from typing import TYPE_CHECKING, Any, ClassVar
from ..runtime.prompt_control_schedule_encode_graph import (
from ..services.prompt_control_schedule_encode_graph import (
PromptControlScheduleEncodeGraphBuilder,
)
+21 -12
View File
@@ -11,7 +11,9 @@ from types import ModuleType
from typing import TYPE_CHECKING, Any, ClassVar
from ..nodes import tooltips
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.flux_artifacts import FLUX_CLIP_L, FLUX_T5_XXL, FLUX_VAE
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES
from ..runtime.vae_loader import vae_choices
@@ -44,9 +46,7 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
"""Declare the separate FLUX.1 loader schema."""
folder_paths = _folder_paths()
text_encoder_choices = _choices_with_auto(
list(folder_paths.get_filename_list("text_encoders"))
)
installed_text_encoders = list(folder_paths.get_filename_list("text_encoders"))
return _comfy_io.Schema(
node_id="SimpleSyrup.SimpleLoadFlux",
display_name="Simple Load FLUX",
@@ -77,7 +77,12 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"clip_l",
options=text_encoder_choices,
options=automatic_component_choices(
installed=installed_text_encoders,
artifacts=(FLUX_CLIP_L,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -87,7 +92,12 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"t5_xxl",
options=text_encoder_choices,
options=automatic_component_choices(
installed=installed_text_encoders,
artifacts=(FLUX_T5_XXL,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -107,7 +117,12 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"vae",
options=_choices_with_auto(vae_choices(folder_paths)),
options=automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(FLUX_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -146,12 +161,6 @@ class SimpleLoadFluxV3(_ComfyNodeBase):
)
def _choices_with_auto(choices: list[str]) -> list[str]:
"""Return deduplicated choices with automatic selection first."""
return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)]
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily for schema declaration."""
+13 -9
View File
@@ -11,7 +11,9 @@ from types import ModuleType
from typing import TYPE_CHECKING, Any, ClassVar
from ..nodes import tooltips
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.flux_artifacts import FLUX2_TEXT_ENCODERS, FLUX2_VAE
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES
from ..runtime.vae_loader import vae_choices
@@ -75,8 +77,11 @@ class SimpleLoadFlux2V3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"text_encoder",
options=_choices_with_auto(
list(folder_paths.get_filename_list("text_encoders"))
options=automatic_component_choices(
installed=list(folder_paths.get_filename_list("text_encoders")),
artifacts=tuple(FLUX2_TEXT_ENCODERS.values()),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
@@ -98,7 +103,12 @@ class SimpleLoadFlux2V3(_ComfyNodeBase):
),
_comfy_io.Combo.Input(
"vae",
options=_choices_with_auto(vae_choices(folder_paths)),
options=automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(FLUX2_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
@@ -135,12 +145,6 @@ class SimpleLoadFlux2V3(_ComfyNodeBase):
)
def _choices_with_auto(choices: list[str]) -> list[str]:
"""Return deduplicated choices with automatic selection first."""
return [AUTO_CHOICE, *(choice for choice in choices if choice != AUTO_CHOICE)]
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily for schema declaration."""
+166
View File
@@ -0,0 +1,166 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Expose cohesive Krea 2 component loading through Comfy's v3 API."""
from __future__ import annotations
import importlib
from types import ModuleType
from typing import TYPE_CHECKING, Any, ClassVar
from ..nodes import tooltips
from ..runtime.auto_model_choices import automatic_component_choices
from ..runtime.diffusion_model_loader import DIFFUSION_WEIGHT_DTYPES
from ..runtime.krea2_artifacts import (
KREA2_AUTO_TEXT_ENCODER,
KREA2_QWEN3_VL_4B_BF16,
KREA2_QWEN3_VL_4B_FP8,
)
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.qwen_artifacts import QWEN_IMAGE_VAE
from ..runtime.text_encoder_loader import TEXT_ENCODER_DEVICES
from ..runtime.vae_loader import vae_choices
from ..services.krea2_loader_service import AUTO_CHOICE, Krea2LoaderService
if TYPE_CHECKING:
class _ComfyNodeBase:
"""Type-checking base for Comfy v3 nodes."""
RETURN_TYPES: ClassVar[list[str]]
RETURN_NAMES: ClassVar[list[str]]
else:
_ComfyNodeBase = importlib.import_module("comfy_api.latest").io.ComfyNode
_comfy_io: Any = (
None if TYPE_CHECKING else importlib.import_module("comfy_api.latest").io
)
class SimpleLoadKrea2V3(_ComfyNodeBase):
"""Load a Krea 2 diffusion model with its Qwen encoder and image VAE."""
_service = Krea2LoaderService()
@classmethod
def define_schema(cls) -> Any:
"""Declare the Krea 2 loader schema and downloadable component choices."""
folder_paths = _folder_paths()
return _comfy_io.Schema(
node_id="SimpleSyrup.SimpleLoadKrea2",
display_name="Simple Load Krea 2",
category="SimpleSyrup/Loaders",
description=(
"Loads Krea 2 with its Qwen3-VL 4B encoder and Qwen Image VAE; "
"automatic components are downloaded from checksum-pinned "
"Hugging Face files."
),
search_aliases=["krea", "krea 2", "k2", "load krea"],
inputs=[
_comfy_io.Combo.Input(
"diffusion_model",
options=list(folder_paths.get_filename_list("diffusion_models")),
tooltip=(
"Krea 2 Raw or Turbo diffusion model to load. This node "
"validates the architecture and never downloads this file."
),
),
_comfy_io.Combo.Input(
"diffusion_weight_dtype",
options=list(DIFFUSION_WEIGHT_DTYPES),
default="default",
advanced=True,
tooltip=(
"Load-time diffusion precision; default preserves the "
"selected file's stored BF16, FP8, INT8, MXFP8, or NVFP4 "
"format."
),
),
_comfy_io.Combo.Input(
"text_encoder",
options=automatic_component_choices(
installed=list(folder_paths.get_filename_list("text_encoders")),
artifacts=(
KREA2_QWEN3_VL_4B_FP8,
KREA2_QWEN3_VL_4B_BF16,
),
leading_choices=(
KREA2_AUTO_TEXT_ENCODER,
KREA2_QWEN3_VL_4B_FP8.filename,
KREA2_QWEN3_VL_4B_BF16.filename,
),
folder_paths_module=folder_paths,
),
default=KREA2_AUTO_TEXT_ENCODER,
advanced=True,
tooltip=(
"Qwen3-VL 4B encoder loaded with Krea 2's required 12-layer "
"conditioning. Auto uses FP8; selecting official FP8 or BF16 "
"downloads that checksum-pinned file when missing."
),
),
_comfy_io.Combo.Input(
"text_encoder_device",
options=list(TEXT_ENCODER_DEVICES),
default="default",
advanced=True,
tooltip=(
"Device for Qwen3-VL; CPU saves GPU memory but makes prompt "
"encoding slower."
),
),
_comfy_io.Combo.Input(
"vae",
options=automatic_component_choices(
installed=vae_choices(folder_paths),
artifacts=(QWEN_IMAGE_VAE,),
leading_choices=(AUTO_CHOICE,),
folder_paths_module=folder_paths,
),
default=AUTO_CHOICE,
advanced=True,
tooltip=(
"VAE used to decode Krea 2 latents. Auto finds or downloads "
"the checksum-pinned Qwen Image VAE with visible progress."
),
),
],
outputs=[
_comfy_io.Model.Output("model", tooltip=tooltips.MODEL_OUTPUT),
_comfy_io.Clip.Output("clip", tooltip=tooltips.CLIP_OUTPUT),
_comfy_io.Vae.Output("vae", tooltip=tooltips.VAE_OUTPUT),
],
)
@classmethod
def execute(
cls,
diffusion_model: str,
diffusion_weight_dtype: str,
text_encoder: str,
text_encoder_device: str,
vae: str,
) -> tuple[object, object, object]:
"""Load and return validated Krea 2 MODEL, CLIP, and VAE objects."""
return cls._service.load_models(
diffusion_model=diffusion_model,
diffusion_weight_dtype=diffusion_weight_dtype,
text_encoder=text_encoder,
text_encoder_device=text_encoder_device,
vae=vae,
progress=ComfyProgressReporter(),
)
def _folder_paths() -> ModuleType:
"""Import ComfyUI folder paths lazily for schema declaration."""
module: Any = importlib.import_module("folder_paths")
if not isinstance(module, ModuleType):
raise TypeError("folder_paths import did not return a module.")
return module
+3 -15
View File
@@ -7,6 +7,7 @@
from __future__ import annotations
from .auto_model_artifact import AutoModelArtifact
from .qwen_artifacts import QWEN_IMAGE_VAE
ANIMA_QWEN_TEXT_ENCODER = AutoModelArtifact(
cache_id="anima_qwen_text_encoder",
@@ -20,20 +21,7 @@ ANIMA_QWEN_TEXT_ENCODER = AutoModelArtifact(
source_repo="circlestone-labs/Anima",
description="Anima Qwen3 0.6B text encoder",
sha256="cd2a512003e2f9f3cd3c32a9c3573f820bb28c940f73c57b1ddaa983d9223eba",
file_size_bytes=1_192_135_096,
)
ANIMA_QWEN_VAE = AutoModelArtifact(
cache_id="anima_qwen_vae",
filename="qwen_image_vae.safetensors",
folder_name="vae",
canonical_subfolder="qwen",
source_url=(
"https://huggingface.co/circlestone-labs/Anima/resolve/main/"
"split_files/vae/qwen_image_vae.safetensors"
),
source_repo="circlestone-labs/Anima",
description="Anima Qwen Image VAE",
sha256="a70580f0213e67967ee9c95f05bb400e8fb08307e017a924bf3441223e023d1f",
)
ANIMA_AUTO_ARTIFACTS = (ANIMA_QWEN_TEXT_ENCODER, ANIMA_QWEN_VAE)
ANIMA_AUTO_ARTIFACTS = (ANIMA_QWEN_TEXT_ENCODER, QWEN_IMAGE_VAE)
@@ -21,3 +21,4 @@ class AutoModelArtifact:
source_repo: str
description: str
sha256: str
file_size_bytes: int
@@ -0,0 +1,72 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Build component choices without duplicating automatic local artifacts."""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from types import ModuleType
from typing import Any
from .auto_model_artifact import AutoModelArtifact
def automatic_component_choices(
installed: Sequence[str],
artifacts: Sequence[AutoModelArtifact],
leading_choices: Sequence[str],
folder_paths_module: ModuleType,
) -> list[str]:
"""Return leading choices plus local files not represented automatically."""
if not artifacts:
raise ValueError("Automatic component choices require at least one artifact.")
folder_names = {artifact.folder_name for artifact in artifacts}
if len(folder_names) != 1:
raise ValueError("Automatic component artifacts must share one model category.")
folder_name = next(iter(folder_names))
automatic_names = {artifact.filename for artifact in artifacts}
automatic_sizes = {artifact.file_size_bytes for artifact in artifacts}
choices = list(dict.fromkeys(leading_choices))
seen = set(choices)
for choice in installed:
if choice in seen or choice in automatic_names:
continue
if _installed_file_has_known_size(
folder_paths_module,
folder_name,
choice,
automatic_sizes,
):
continue
choices.append(choice)
seen.add(choice)
return choices
def _installed_file_has_known_size(
folder_paths_module: ModuleType,
folder_name: str,
choice: str,
automatic_sizes: set[int],
) -> bool:
"""Identify a likely automatic artifact without hashing during schema creation."""
get_full_path: Any = getattr(folder_paths_module, "get_full_path", None)
if not callable(get_full_path):
return False
path_value: Any = get_full_path(folder_name, choice)
if path_value is None:
return False
try:
path = Path(str(path_value))
return path.is_file() and path.stat().st_size in automatic_sizes
except OSError:
return False
__all__ = ["automatic_component_choices"]
+74 -6
View File
@@ -150,8 +150,6 @@ class AutoModelResolver:
return False
if entry.sha256 != artifact.sha256:
return False
if entry.path.name != artifact.filename:
return False
if not entry.path.is_file():
return False
try:
@@ -163,6 +161,8 @@ class AutoModelResolver:
except ValueError:
return False
stat = entry.path.stat()
if stat.st_size != artifact.file_size_bytes:
return False
if entry.file_size is not None and entry.modified_time_ns is not None:
return (
entry.file_size == stat.st_size
@@ -175,19 +175,33 @@ def find_model_artifact(
artifact: AutoModelArtifact,
folder_paths_module: ModuleType | None = None,
) -> Path | None:
"""Return the first same-named local file matching the catalog checksum."""
"""Find an artifact by the cheapest reliable checks within its model category."""
_validate_basename(artifact.filename)
for root in get_model_folder_paths(artifact.folder_name, folder_paths_module):
roots = get_model_folder_paths(artifact.folder_name, folder_paths_module)
canonical = canonical_auto_destination(artifact, folder_paths_module)
if _candidate_matches_artifact(canonical, artifact):
return canonical
checked: set[Path] = {canonical.resolve()}
for root in roots:
if not root.is_dir():
continue
matches = sorted(
path for path in root.rglob(artifact.filename) if path.is_file()
)
for match in matches:
if match.name != artifact.filename or not _path_is_under(match, root):
if not _candidate_has_expected_size(match, artifact):
continue
if sha256_file(match).lower() == artifact.sha256.lower():
resolved_match = match.resolve()
if (
resolved_match in checked
or match.name != artifact.filename
or not _path_is_under(match, root)
):
continue
checked.add(resolved_match)
if _candidate_checksum_matches(match, artifact):
return match
LOGGER.warning(
"same-named auto model artifact has a different checksum",
@@ -197,9 +211,63 @@ def find_model_artifact(
"artifact_filename": artifact.filename,
},
)
for root in roots:
if not root.is_dir():
continue
for candidate in root.rglob("*"):
if not _candidate_has_expected_size(candidate, artifact):
continue
if not _path_is_under(candidate, root):
continue
resolved_candidate = candidate.resolve()
if resolved_candidate in checked:
continue
checked.add(resolved_candidate)
if not _candidate_checksum_matches(candidate, artifact):
continue
LOGGER.info(
"auto model artifact found under alternate filename",
extra={
"cache_id": artifact.cache_id,
"path": str(candidate),
"artifact_filename": artifact.filename,
},
)
return candidate
return None
def _candidate_matches_artifact(
candidate: Path,
artifact: AutoModelArtifact,
) -> bool:
"""Verify a candidate only when its inexpensive file checks match first."""
return _candidate_has_expected_size(
candidate,
artifact,
) and _candidate_checksum_matches(candidate, artifact)
def _candidate_has_expected_size(
candidate: Path,
artifact: AutoModelArtifact,
) -> bool:
"""Return whether a regular file has the artifact's exact byte size."""
return candidate.is_file() and candidate.stat().st_size == artifact.file_size_bytes
def _candidate_checksum_matches(
candidate: Path,
artifact: AutoModelArtifact,
) -> bool:
"""Return whether an already size-matched candidate has the trusted digest."""
return sha256_file(candidate).lower() == artifact.sha256.lower()
def find_model_by_basename(
folder_name: str,
basename: str,
+25
View File
@@ -0,0 +1,25 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Validate ComfyUI text-encoder type availability before model downloads."""
from __future__ import annotations
import importlib
from typing import Any
class ComfyClipTypeSupport:
"""Validate named ComfyUI CLIP types at the runtime boundary."""
def require(self, clip_type_name: str) -> None:
"""Raise an actionable error when ComfyUI lacks a required CLIP type."""
comfy_sd: Any = importlib.import_module("comfy.sd")
if hasattr(comfy_sd.CLIPType, clip_type_name):
return
raise RuntimeError(
f"This loader requires ComfyUI CLIP type '{clip_type_name}'. "
"Update ComfyUI before using this node."
)
@@ -13,6 +13,7 @@ from uuid import UUID
import torch
from comfy import sampler_helpers, samplers
from ..domain.attention_coupling_preparation import AttentionCouplingPreparation
from ..domain.conditioning_schedule import ConditioningScheduleRange
from ..domain.processed_regional_attention import (
ProcessedRegionalAttentionBranch,
@@ -23,9 +24,6 @@ from ..domain.processed_regional_attention import (
from ..domain.raw_regional_attention import (
RawRegionalAttentionBranch,
)
from ..services.attention_coupling_preparation_service import (
AttentionCouplingPreparation,
)
from .attention_coupling.context_validation import RegionalContextValidator
from .ppm_negpip_interop import PpmNegpipInterop
@@ -0,0 +1,81 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Convert and align image assets used by detail sampling previews."""
from __future__ import annotations
import numpy as np
import torch
from PIL import Image
from ..domain.segs import CropRegion
CropBox = tuple[int, int, int, int]
def image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image:
"""Convert a single-image BHWC tensor to an RGB PIL image."""
if image.ndim != 4:
raise ValueError("detail preview image must be a BHWC tensor.")
if int(image.shape[0]) != 1:
raise ValueError("detail preview image must contain exactly one image.")
if int(image.shape[-1]) < 1:
raise ValueError("detail preview image must contain at least one channel.")
array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy()
if array.shape[-1] == 1:
array = np.repeat(array, 3, axis=-1)
elif array.shape[-1] >= 3:
array = array[..., :3]
else:
array = np.repeat(array[..., :1], 3, axis=-1)
return Image.fromarray((array * 255.0).round().astype(np.uint8))
def normalize_mask_tensor(mask: torch.Tensor) -> torch.Tensor:
"""Normalize an HW or single-item BHW mask tensor to HW float."""
working = mask.detach().float()
if working.ndim == 3 and int(working.shape[0]) == 1:
working = working[0]
if working.ndim != 2:
raise ValueError("detail preview work mask must be an HW tensor.")
return working
def detail_alpha_mask(
mask: torch.Tensor,
*,
source_size: tuple[int, int],
preview_size: tuple[int, int],
sampled_box: CropBox,
target_size: tuple[int, int],
) -> Image.Image:
"""Return an alpha mask aligned to the sampled preview paste box."""
working = normalize_mask_tensor(mask).detach().cpu().clamp(0.0, 1.0)
mask_image = Image.fromarray((working.numpy() * 255.0).round().astype(np.uint8))
if mask_image.size == source_size:
preview_mask = mask_image.resize(preview_size, Image.Resampling.BILINEAR)
return preview_mask.crop(sampled_box).resize(
target_size,
Image.Resampling.BILINEAR,
)
return mask_image.resize(target_size, Image.Resampling.BILINEAR)
def validate_crop_region(
crop_region: CropRegion,
source_width: int,
source_height: int,
) -> None:
"""Reject crop regions that cannot be mapped into the source image."""
if crop_region.left < 0 or crop_region.top < 0:
raise ValueError("crop_region left and top must be non-negative.")
if crop_region.right <= crop_region.left or crop_region.bottom <= crop_region.top:
raise ValueError("crop_region right/bottom must be greater than left/top.")
if crop_region.right > source_width or crop_region.bottom > source_height:
raise ValueError("crop_region must fit within the source image.")
+12 -83
View File
@@ -15,6 +15,12 @@ import torch
from PIL import Image, ImageDraw
from ..domain.segs import CropRegion
from .detail_preview_images import (
detail_alpha_mask,
image_tensor_to_rgb_pil,
normalize_mask_tensor,
validate_crop_region,
)
DETAIL_PREVIEW_WASH_OPACITY = 0.55
DETAIL_PREVIEW_OUTLINE_RGB = (255, 0, 0)
@@ -126,7 +132,7 @@ class DetailPreviewCompositor:
) -> DetailPreviewCompositor:
"""Create a compositor with detailer background work precomputed."""
source_image = _image_tensor_to_rgb_pil(context.image)
source_image = image_tensor_to_rgb_pil(context.image)
sampled_region = context.sampled_region or context.work_region
geometry = build_detail_preview_geometry(
source_width=source_image.width,
@@ -147,7 +153,7 @@ class DetailPreviewCompositor:
)
left, top, right, bottom = geometry.crop_box
crop_size = (max(1, right - left), max(1, bottom - top))
detail_alpha_mask = _detail_alpha_mask(
detail_alpha = detail_alpha_mask(
context.work_mask,
source_size=(source_image.width, source_image.height),
preview_size=geometry.preview_size,
@@ -157,7 +163,7 @@ class DetailPreviewCompositor:
return cls(
geometry=geometry,
washed_background=washed_background,
detail_alpha_mask=detail_alpha_mask,
detail_alpha_mask=detail_alpha,
)
def compose(self, crop_preview: Image.Image) -> Image.Image:
@@ -211,9 +217,9 @@ def build_detail_preview_geometry(
source_height,
max_preview_resolution,
)
_validate_crop_region(crop_region, source_width, source_height)
validate_crop_region(crop_region, source_width, source_height)
resolved_outline_region = outline_region or crop_region
_validate_crop_region(resolved_outline_region, source_width, source_height)
validate_crop_region(resolved_outline_region, source_width, source_height)
preview_width, preview_height = preview_size
scale_x = float(preview_width) / float(source_width)
@@ -238,7 +244,7 @@ def build_detail_preview_geometry(
def work_region_from_mask(mask: torch.Tensor) -> CropRegion:
"""Return the tight work region around a non-empty HW or single-item BHW mask."""
working = _normalize_mask_tensor(mask)
working = normalize_mask_tensor(mask)
coordinates = torch.nonzero(working > 0, as_tuple=False)
if coordinates.numel() == 0:
raise ValueError("detail preview work mask must contain at least one pixel.")
@@ -367,83 +373,6 @@ def _source_outline_box(
)
def _image_tensor_to_rgb_pil(image: torch.Tensor) -> Image.Image:
"""Convert a single-image BHWC tensor to an RGB PIL image."""
import numpy as np
if image.ndim != 4:
raise ValueError("detail preview image must be a BHWC tensor.")
if int(image.shape[0]) != 1:
raise ValueError("detail preview image must contain exactly one image.")
if int(image.shape[-1]) < 1:
raise ValueError("detail preview image must contain at least one channel.")
array = image[0].detach().cpu().float().clamp(0.0, 1.0).numpy()
if array.shape[-1] == 1:
array = np.repeat(array, 3, axis=-1)
elif array.shape[-1] >= 3:
array = array[..., :3]
else:
array = np.repeat(array[..., :1], 3, axis=-1)
return Image.fromarray((array * 255.0).round().astype(np.uint8))
def _mask_tensor_to_l_pil(mask: torch.Tensor) -> Image.Image:
"""Convert an HW or single-item BHW mask tensor to a grayscale alpha image."""
import numpy as np
working = _normalize_mask_tensor(mask).detach().cpu().clamp(0.0, 1.0)
return Image.fromarray((working.numpy() * 255.0).round().astype(np.uint8))
def _normalize_mask_tensor(mask: torch.Tensor) -> torch.Tensor:
"""Normalize an HW or single-item BHW mask tensor to HW float."""
working = mask.detach().float()
if working.ndim == 3 and int(working.shape[0]) == 1:
working = working[0]
if working.ndim != 2:
raise ValueError("detail preview work mask must be an HW tensor.")
return working
def _detail_alpha_mask(
mask: torch.Tensor,
*,
source_size: tuple[int, int],
preview_size: tuple[int, int],
sampled_box: CropBox,
target_size: tuple[int, int],
) -> Image.Image:
"""Return an alpha mask aligned to the sampled preview paste box."""
mask_image = _mask_tensor_to_l_pil(mask)
if mask_image.size == source_size:
preview_mask = mask_image.resize(preview_size, Image.Resampling.BILINEAR)
return preview_mask.crop(sampled_box).resize(
target_size,
Image.Resampling.BILINEAR,
)
return mask_image.resize(target_size, Image.Resampling.BILINEAR)
def _validate_crop_region(
crop_region: CropRegion,
source_width: int,
source_height: int,
) -> None:
"""Reject crop regions that cannot be mapped into the source image."""
if crop_region.left < 0 or crop_region.top < 0:
raise ValueError("crop_region left and top must be non-negative.")
if crop_region.right <= crop_region.left or crop_region.bottom <= crop_region.top:
raise ValueError("crop_region right/bottom must be greater than left/top.")
if crop_region.right > source_width or crop_region.bottom > source_height:
raise ValueError("crop_region must fit within the source image.")
def _validate_positive_int(name: str, value: int) -> None:
"""Reject non-positive integer values."""
@@ -0,0 +1,65 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Inspect tensor-derived metadata exposed by loaded ComfyUI diffusion models."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
@dataclass(frozen=True)
class DiffusionModelMetadata:
"""Describe architecture fields relevant to component selection."""
image_model: str | None
context_input_dimension: int | None
@runtime_checkable
class ModelPatcherBoundary(Protocol):
"""Expose the loaded model objects required for architecture inspection."""
def get_model_object(self, name: str) -> object:
"""Return a named object owned by ComfyUI's model patcher."""
class DiffusionModelMetadataInspector:
"""Read normalized architecture metadata from a loaded model patcher."""
def inspect(self, model: object) -> DiffusionModelMetadata | None:
"""Return narrowed model metadata or None when it is unavailable."""
if not isinstance(model, ModelPatcherBoundary):
return None
try:
model_config = model.get_model_object("model_config")
except (AttributeError, KeyError, TypeError, ValueError):
LOGGER.warning(
"loaded model does not expose inspectable model configuration"
)
return None
unet_config = getattr(model_config, "unet_config", None)
if not isinstance(unet_config, Mapping):
return None
image_model_value = unet_config.get("image_model")
context_dimension_value = unet_config.get("context_in_dim")
return DiffusionModelMetadata(
image_model=(
image_model_value if isinstance(image_model_value, str) else None
),
context_input_dimension=(
context_dimension_value
if isinstance(context_dimension_value, int)
and not isinstance(context_dimension_value, bool)
else None
),
)
+1 -1
View File
@@ -13,7 +13,7 @@ import torch
from PIL import Image
from ..domain.segs import Segment
from ..masking.segs_mask_ops import crop_image, crop_mask, resize_mask
from ..domain.segs_mask_ops import crop_image, crop_mask, resize_mask
from ..shared.tensor_validation import validate_image_tensor
SEG_IMAGE_MODES = ("transparent mask", "black mask", "full crop")
+7
View File
@@ -21,6 +21,7 @@ FLUX_CLIP_L = AutoModelArtifact(
source_repo="comfyanonymous/flux_text_encoders",
description="FLUX CLIP-L text encoder",
sha256="660c6f5b1abae9dc498ac2d21e1347d2abdb0cf6c0c0c8576cd796491d9a6cdd",
file_size_bytes=246_144_152,
)
FLUX_T5_XXL = AutoModelArtifact(
@@ -35,6 +36,7 @@ FLUX_T5_XXL = AutoModelArtifact(
source_repo="comfyanonymous/flux_text_encoders",
description="FLUX T5-XXL FP16 text encoder",
sha256="6e480b09fae049a72d2a8c5fbccb8d3e92febeb233bbe9dfe7256958a9167635",
file_size_bytes=9_787_841_024,
)
FLUX_VAE = AutoModelArtifact(
@@ -50,6 +52,7 @@ FLUX_VAE = AutoModelArtifact(
source_repo="Comfy-Org/Lumina_Image_2.0_Repackaged",
description="FLUX autoencoder VAE",
sha256="afc8e28272cd15db3919bacdb6918ce9c1ed22e96cb12c4d5ed0fba823529e38",
file_size_bytes=335_304_388,
)
FLUX2_DEV_TEXT_ENCODER = AutoModelArtifact(
@@ -65,6 +68,7 @@ FLUX2_DEV_TEXT_ENCODER = AutoModelArtifact(
source_repo="Comfy-Org/flux2-dev",
description="FLUX.2 dev Mistral 3 Small text encoder",
sha256="7d79902f60b1aeb3a6de2cfad02f4367b5e300a1387de3d03ac717cfa3df117c",
file_size_bytes=35_584_897_447,
)
FLUX2_KLEIN_4B_TEXT_ENCODER = AutoModelArtifact(
@@ -80,6 +84,7 @@ FLUX2_KLEIN_4B_TEXT_ENCODER = AutoModelArtifact(
source_repo="Comfy-Org/vae-text-encorder-for-flux-klein-4b",
description="FLUX.2 Klein 4B Qwen3 text encoder",
sha256="6c671498573ac2f7a5501502ccce8d2b08ea6ca2f661c458e708f36b36edfc5a",
file_size_bytes=8_044_982_048,
)
FLUX2_KLEIN_9B_TEXT_ENCODER = AutoModelArtifact(
@@ -95,6 +100,7 @@ FLUX2_KLEIN_9B_TEXT_ENCODER = AutoModelArtifact(
source_repo="Comfy-Org/vae-text-encorder-for-flux-klein-9b",
description="FLUX.2 Klein 9B Qwen3 8B FP8-mixed text encoder",
sha256="abad16806e0cbabc54e0325d6565847443fe396d5f0be38bb3cd3fe75a1201d6",
file_size_bytes=8_664_848_742,
)
FLUX2_VAE = AutoModelArtifact(
@@ -110,6 +116,7 @@ FLUX2_VAE = AutoModelArtifact(
source_repo="Comfy-Org/flux2-dev",
description="FLUX.2 VAE",
sha256="d64f3a68e1cc4f9f4e29b6e0da38a0204fe9a49f2d4053f0ec1fa1ca02f9c4b5",
file_size_bytes=336_213_556,
)
FLUX2_TEXT_ENCODERS: dict[Flux2TextEncoderProfile, AutoModelArtifact] = {
+27 -34
View File
@@ -6,49 +6,42 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Protocol, runtime_checkable
from typing import Protocol
from ..domain.flux_profiles import FluxModelProfile, classify_flux_profile
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
@runtime_checkable
class ModelPatcherBoundary(Protocol):
"""Expose the loaded model objects required for architecture inspection."""
def get_model_object(self, name: str) -> object:
"""Return a named object owned by ComfyUI's model patcher."""
from .diffusion_model_metadata import (
DiffusionModelMetadata,
DiffusionModelMetadataInspector,
)
class FluxModelInspector:
"""Read ComfyUI's tensor-derived model configuration after model loading."""
def __init__(
self,
metadata_inspector: DiffusionModelMetadataInspectorBoundary | None = None,
) -> None:
"""Create a FLUX classifier over shared metadata inspection."""
self._metadata_inspector = (
metadata_inspector or DiffusionModelMetadataInspector()
)
def inspect(self, model: object) -> FluxModelProfile | None:
"""Return a detected FLUX profile or None for unavailable metadata."""
if not isinstance(model, ModelPatcherBoundary):
metadata = self._metadata_inspector.inspect(model)
if metadata is None:
return None
try:
model_config = model.get_model_object("model_config")
except (AttributeError, KeyError, TypeError, ValueError):
LOGGER.warning(
"loaded model does not expose inspectable model configuration"
)
return None
unet_config = getattr(model_config, "unet_config", None)
if not isinstance(unet_config, Mapping):
return None
image_model_value = unet_config.get("image_model")
context_dimension_value = unet_config.get("context_in_dim")
image_model = image_model_value if isinstance(image_model_value, str) else None
context_dimension = (
context_dimension_value
if isinstance(context_dimension_value, int)
and not isinstance(context_dimension_value, bool)
else None
return classify_flux_profile(
metadata.image_model,
metadata.context_input_dimension,
)
return classify_flux_profile(image_model, context_dimension)
class DiffusionModelMetadataInspectorBoundary(Protocol):
"""Expose normalized loaded diffusion-model metadata."""
def inspect(self, model: object) -> DiffusionModelMetadata | None:
"""Return normalized metadata when available."""
+56
View File
@@ -0,0 +1,56 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Declare trusted automatic artifacts and selections for Krea 2."""
from __future__ import annotations
from .auto_model_artifact import AutoModelArtifact
KREA2_AUTO_TEXT_ENCODER = "auto"
KREA2_QWEN3_VL_4B_FP8 = AutoModelArtifact(
cache_id="krea2_qwen3vl_4b_fp8_scaled",
filename="qwen3vl_4b_fp8_scaled.safetensors",
folder_name="text_encoders",
canonical_subfolder="krea2",
source_url=(
"https://huggingface.co/Comfy-Org/Krea-2/resolve/"
"e5ea8b4dd7f38f348b138eb0fe29f92c0e367e96/text_encoders/"
"qwen3vl_4b_fp8_scaled.safetensors"
),
source_repo="Comfy-Org/Krea-2",
description="Krea 2 Qwen3-VL 4B FP8-scaled text encoder",
sha256="54bd5144df0bbc25dd6ccadfcb826b521445a1b06ae5a42570bdd2974ca87094",
file_size_bytes=5_242_467_968,
)
KREA2_QWEN3_VL_4B_BF16 = AutoModelArtifact(
cache_id="krea2_qwen3vl_4b_bf16",
filename="qwen3vl_4b_bf16.safetensors",
folder_name="text_encoders",
canonical_subfolder="krea2",
source_url=(
"https://huggingface.co/Comfy-Org/Krea-2/resolve/"
"e5ea8b4dd7f38f348b138eb0fe29f92c0e367e96/text_encoders/"
"qwen3vl_4b_bf16.safetensors"
),
source_repo="Comfy-Org/Krea-2",
description="Krea 2 Qwen3-VL 4B BF16 text encoder",
sha256="36f3ff447ef59201722e8f9ce6020c9819fdcfba6aa2608c4e09b1c0ce114e34",
file_size_bytes=8_875_719_384,
)
KREA2_TEXT_ENCODER_ARTIFACTS = {
KREA2_QWEN3_VL_4B_FP8.filename: KREA2_QWEN3_VL_4B_FP8,
KREA2_QWEN3_VL_4B_BF16.filename: KREA2_QWEN3_VL_4B_BF16,
}
__all__ = [
"KREA2_AUTO_TEXT_ENCODER",
"KREA2_QWEN3_VL_4B_BF16",
"KREA2_QWEN3_VL_4B_FP8",
"KREA2_TEXT_ENCODER_ARTIFACTS",
]
+27
View File
@@ -0,0 +1,27 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Declare shared checksum-pinned Qwen model artifacts."""
from __future__ import annotations
from .auto_model_artifact import AutoModelArtifact
QWEN_IMAGE_VAE = AutoModelArtifact(
cache_id="qwen_image_vae",
filename="qwen_image_vae.safetensors",
folder_name="vae",
canonical_subfolder="qwen",
source_url=(
"https://huggingface.co/Comfy-Org/Krea-2/resolve/"
"e5ea8b4dd7f38f348b138eb0fe29f92c0e367e96/vae/"
"qwen_image_vae.safetensors"
),
source_repo="Comfy-Org/Krea-2",
description="Qwen Image VAE",
sha256="a70580f0213e67967ee9c95f05bb400e8fb08307e017a924bf3441223e023d1f",
file_size_bytes=253_806_246,
)
__all__ = ["QWEN_IMAGE_VAE"]
@@ -0,0 +1,72 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Adapt shared detail sampling to regional MultiDiffusion runtime calls."""
from __future__ import annotations
from typing import Any
import torch
from ..domain.regional_detailing import LatentRegion
from . import regional_multidiffusion_sampling
from .detail_previews import DetailPreviewContext
from .detail_sampling import DetailSampler, Latent
class RegionalDetailSampler:
"""Adapt shared detail sampling helpers to regional MultiDiffusion."""
def __init__(self, detail_sampler: DetailSampler | None = None) -> None:
"""Create the runtime adapter with injectable encode/decode behavior."""
self._detail_sampler = detail_sampler or DetailSampler()
def encode(self, vae: Any, pixels: torch.Tensor, tiled: bool) -> Latent:
"""Encode pixels into a latent dictionary."""
return self._detail_sampler.encode(vae, pixels, tiled)
def decode(self, vae: Any, latent: Latent, tiled: bool) -> torch.Tensor:
"""Decode latent samples into pixels."""
return self._detail_sampler.decode(vae, latent, tiled)
def sample_regions(
self,
*,
model: Any,
seed: int,
steps: int,
cfg: float,
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: Latent,
regions: tuple[LatentRegion, ...],
denoise: float,
global_prompt_weight: float,
preview_context: DetailPreviewContext | None = None,
differential_diffusion: bool = False,
) -> Latent:
"""Sample one full latent with regional MultiDiffusion."""
return regional_multidiffusion_sampling.sample_regional_multidiffusion(
model=model,
seed=seed,
steps=steps,
cfg=cfg,
sampler_name=sampler_name,
scheduler=scheduler,
positive=positive,
negative=negative,
latent_image=latent_image,
regions=regions,
denoise=denoise,
global_prompt_weight=global_prompt_weight,
preview_context=preview_context,
differential_diffusion=differential_diffusion,
)
@@ -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
@@ -29,6 +26,11 @@ from .differential_diffusion import (
)
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 +41,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)
@@ -193,7 +194,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 +235,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 +257,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 +290,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."""
+1 -1
View File
@@ -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)
-544
View File
@@ -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)
@@ -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:
@@ -21,6 +21,7 @@ from ..domain.regional_detailing import (
pair_segments_with_conditioning,
)
from ..domain.segs import CropRegion, coerce_segs
from ..domain.segs_mask_ops import validate_single_image
from ..masking.regional_detailing_masks import (
build_image_regions,
build_latent_regions,
@@ -28,11 +29,10 @@ from ..masking.regional_detailing_masks import (
scale_image_regions,
union_masks,
)
from ..masking.segs_mask_ops import validate_single_image
from ..runtime import regional_multidiffusion_sampling
from ..runtime.detail_previews import DetailPreviewContext, work_region_from_mask
from ..runtime.detail_resize import DetailImageResizer
from ..runtime.detail_sampling import DetailSampler, Latent
from ..runtime.detail_sampling import Latent
from ..runtime.regional_detail_sampler import RegionalDetailSampler
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
@@ -97,62 +97,6 @@ class DetailSEGSAsRegionsResult:
image: torch.Tensor
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 helper."""
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,
)
class DetailSEGSAsRegionsService:
"""Detail provided SEGS through one regional MultiDiffusion pass."""
@@ -14,12 +14,12 @@ import torch
from ..domain.conditioning_batch import select_conditioning
from ..domain.detail_geometry import DetailScalePlan, build_detail_scale_plan
from ..domain.segs import Segment, coerce_segs
from ..image.crop_composite import composite_crop
from ..masking.detailer_masks import gaussian_feather_mask
from ..masking.segs_mask_ops import (
from ..domain.segs_mask_ops import (
crop_image,
validate_single_image,
)
from ..image.crop_composite import composite_crop
from ..masking.detailer_masks import gaussian_feather_mask
from ..runtime.detail_previews import DetailPreviewContext
from ..runtime.detail_resize import DetailImageResizer
from ..runtime.detail_sampling import DetailSampler, Latent
@@ -14,49 +14,22 @@ import torch
from ..domain.conditioning_batch import select_conditioning
from ..domain.detail_geometry import DetailScalePlan, build_detail_scale_plan
from ..domain.segs import Segment, coerce_segs
from ..domain.tiled_diffusion import validate_tiled_diffusion_mode
from ..image.crop_composite import composite_crop
from ..masking.detailer_masks import gaussian_feather_mask
from ..masking.segs_mask_ops import (
from ..domain.segs_mask_ops import (
crop_image,
validate_single_image,
)
from ..domain.tiled_diffusion import validate_tiled_diffusion_mode
from ..image.crop_composite import composite_crop
from ..masking.detailer_masks import gaussian_feather_mask
from ..runtime.detail_previews import DetailPreviewContext
from ..runtime.detail_resize import DetailImageResizer
from ..runtime.detail_sampling import DetailSampler, Latent
from ..runtime.detail_sampling import Latent
from ..shared.logging import get_logger
from .tiled_diffusion_sampling_service import TiledDiffusionSamplingService
from .tiled_detail_sampler import TiledDetailSampler
LOGGER = get_logger(__name__)
class TiledDiffusionLatentSamplingBoundary(Protocol):
"""Latent sampling boundary for selectable tiled diffusion modes."""
def sample(
self,
*,
diffusion_mode: str,
model: Any,
seed: int,
steps: int,
cfg: float,
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: Latent,
denoise: float,
latent_tile_width: int,
latent_tile_height: int,
latent_tile_overlap: int,
latent_tile_batch_size: int,
preview_context: DetailPreviewContext | None = None,
differential_diffusion: bool = False,
) -> Latent:
"""Sample a latent using the selected tiled diffusion mode."""
class TiledDetailSamplingBoundary(Protocol):
"""Runtime boundary used by tiled scale-factor detailing."""
@@ -118,75 +91,6 @@ class TiledDetailerResult:
image: torch.Tensor
class TiledDetailSampler:
"""Adapt shared VAE helpers and tiled diffusion runtimes."""
def __init__(
self,
detail_sampler: DetailSampler | None = None,
tiled_sampling_service: TiledDiffusionLatentSamplingBoundary | None = None,
) -> None:
"""Create the adapter with injectable encode/decode behavior."""
self._detail_sampler = detail_sampler or DetailSampler()
self._tiled_sampling_service = (
tiled_sampling_service or TiledDiffusionSamplingService()
)
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_tiled(
self,
*,
diffusion_mode: str,
model: Any,
seed: int,
steps: int,
cfg: float,
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: Latent,
denoise: float,
latent_tile_width: int,
latent_tile_height: int,
latent_tile_overlap: int,
latent_tile_batch_size: int,
preview_context: DetailPreviewContext | None = None,
differential_diffusion: bool = False,
) -> Latent:
"""Sample one latent crop with the selected tiled diffusion runtime."""
return self._tiled_sampling_service.sample(
diffusion_mode=diffusion_mode,
model=model,
seed=seed,
steps=steps,
cfg=cfg,
sampler_name=sampler_name,
scheduler=scheduler,
positive=positive,
negative=negative,
latent_image=latent_image,
denoise=denoise,
latent_tile_width=latent_tile_width,
latent_tile_height=latent_tile_height,
latent_tile_overlap=latent_tile_overlap,
latent_tile_batch_size=latent_tile_batch_size,
preview_context=preview_context,
differential_diffusion=differential_diffusion,
)
class DetailSEGSByScaleFactorTiledDiffusionService:
"""Detail SEGS crops with tiled diffusion latent sampling."""
@@ -2,14 +2,15 @@
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Impact-style detector facades backed by native SimpleSyrup services."""
"""Expose Impact-compatible detector facades over native detection services."""
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from ..domain.segs import to_impact_compatible_segs
from .ultralytics_loader import UltralyticsDetectorModel
from ..runtime.ultralytics_model_adapter import UltralyticsDetectorModel
from .segs_detection_service import SegsDetectionService
@dataclass(frozen=True)
@@ -17,6 +18,11 @@ class BBoxDetectorFacade:
"""Expose a bbox detector-shaped object for existing workflows."""
detector_model: UltralyticsDetectorModel
detection_service: SegsDetectionService = field(
default_factory=SegsDetectionService,
repr=False,
compare=False,
)
def detect(
self,
@@ -30,9 +36,7 @@ class BBoxDetectorFacade:
"""Detect rectangular SEGS through the native detection service."""
del detailer_hook
from ..services.segs_detection_service import SegsDetectionService
segs = SegsDetectionService().detect(
segs = self.detection_service.detect(
image=image,
detector_model=self.detector_model,
threshold=threshold,
@@ -50,6 +54,11 @@ class SegmDetectorFacade:
detector_model: UltralyticsDetectorModel
bbox_detector: BBoxDetectorFacade
detection_service: SegsDetectionService = field(
default_factory=SegsDetectionService,
repr=False,
compare=False,
)
def detect(
self,
@@ -63,9 +72,7 @@ class SegmDetectorFacade:
"""Detect segmentation SEGS when available, otherwise rectangular SEGS."""
del detailer_hook
from ..services.segs_detection_service import SegsDetectionService
segs = SegsDetectionService().detect(
segs = self.detection_service.detect(
image=image,
detector_model=self.detector_model,
threshold=threshold,
@@ -0,0 +1,196 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Load and validate the diffusion, text encoder, and VAE for Krea 2."""
from __future__ import annotations
import importlib
from pathlib import Path
from types import ModuleType
from typing import Any, Protocol
from ..runtime.auto_model_artifact import AutoModelArtifact
from ..runtime.auto_model_resolver import AutoModelResolution, AutoModelResolver
from ..runtime.clip_type_support import ComfyClipTypeSupport
from ..runtime.diffusion_model_loader import DiffusionModelLoader
from ..runtime.diffusion_model_metadata import (
DiffusionModelMetadata,
DiffusionModelMetadataInspector,
)
from ..runtime.krea2_artifacts import (
KREA2_AUTO_TEXT_ENCODER,
KREA2_QWEN3_VL_4B_FP8,
KREA2_TEXT_ENCODER_ARTIFACTS,
)
from ..runtime.model_downloads import ProgressReporter
from ..runtime.qwen_artifacts import QWEN_IMAGE_VAE
from ..runtime.text_encoder_loader import TextEncoderLoader
from ..runtime.vae_loader import VaeLoaderService, load_vae_path
AUTO_CHOICE = "auto"
KREA2_CLIP_TYPE = "KREA2"
KREA2_IMAGE_MODEL = "krea2"
class Krea2LoaderService:
"""Orchestrate structurally validated Krea 2 component loading."""
def __init__(
self,
diffusion_loader: DiffusionModelLoaderBoundary | None = None,
text_encoder_loader: TextEncoderLoaderBoundary | None = None,
model_inspector: DiffusionModelInspectorBoundary | None = None,
clip_type_support: ClipTypeSupportBoundary | None = None,
resolver: AutoModelResolverBoundary | None = None,
vae_loader: VaeLoaderBoundary | None = None,
folder_paths_module: ModuleType | None = None,
) -> None:
"""Create a loader with injectable host and artifact boundaries."""
self._folder_paths_module = folder_paths_module
self._diffusion_loader = diffusion_loader or DiffusionModelLoader(
folder_paths_module
)
self._text_encoder_loader = text_encoder_loader or TextEncoderLoader(
folder_paths_module
)
self._model_inspector = model_inspector or DiffusionModelMetadataInspector()
self._clip_type_support = clip_type_support or ComfyClipTypeSupport()
self._resolver = resolver or AutoModelResolver(
folder_paths_module=folder_paths_module
)
self._vae_loader = vae_loader or VaeLoaderService(folder_paths_module)
def load_models(
self,
diffusion_model: str,
diffusion_weight_dtype: str,
text_encoder: str,
text_encoder_device: str,
vae: str,
progress: ProgressReporter | None = None,
) -> tuple[object, object, object]:
"""Return a validated Krea 2 MODEL, CLIP, and VAE tuple."""
model = self._diffusion_loader.load(
diffusion_model,
diffusion_weight_dtype,
)
self._require_krea2_model(model)
self._clip_type_support.require(KREA2_CLIP_TYPE)
encoder_path = self._resolve_text_encoder(text_encoder, progress)
clip = self._text_encoder_loader.load(
(encoder_path,),
KREA2_CLIP_TYPE,
text_encoder_device,
)
loaded_vae = self._load_vae(vae, progress)
return model, clip, loaded_vae
def _require_krea2_model(self, model: object) -> None:
"""Reject non-Krea architectures before resolving large support files."""
metadata = self._model_inspector.inspect(model)
if metadata is not None and metadata.image_model == KREA2_IMAGE_MODEL:
return
raise ValueError(
"Simple Load Krea 2 requires a diffusion model ComfyUI recognizes "
"as Krea 2. Select a Krea 2 Raw or Turbo diffusion model."
)
def _resolve_text_encoder(
self,
selection: str,
progress: ProgressReporter | None,
) -> Path:
"""Resolve auto and official selections or a local manual encoder."""
artifact = (
KREA2_QWEN3_VL_4B_FP8
if selection == KREA2_AUTO_TEXT_ENCODER
else KREA2_TEXT_ENCODER_ARTIFACTS.get(selection)
)
if artifact is not None:
return self._resolver.resolve(artifact, progress).path
path = self._folder_paths().get_full_path_or_raise(
"text_encoders",
selection,
)
return Path(str(path))
def _load_vae(
self,
selection: str,
progress: ProgressReporter | None,
) -> object:
"""Load the shared Qwen Image VAE automatically or a manual VAE."""
if selection == AUTO_CHOICE:
path = self._resolver.resolve(QWEN_IMAGE_VAE, progress).path
return load_vae_path(path)
return self._vae_loader.load_vae(selection)
def _folder_paths(self) -> ModuleType:
"""Return the configured ComfyUI folder-path registry."""
if self._folder_paths_module is not None:
return self._folder_paths_module
module: Any = 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
class DiffusionModelLoaderBoundary(Protocol):
"""Load one selected standalone diffusion model."""
def load(self, diffusion_model: str, weight_dtype: str) -> object:
"""Return a loaded ComfyUI model patcher."""
class TextEncoderLoaderBoundary(Protocol):
"""Load one or more text-encoder files for a named Comfy CLIP type."""
def load(
self,
paths: tuple[Path, ...],
clip_type_name: str,
device: str,
) -> object:
"""Return a loaded ComfyUI CLIP object."""
class DiffusionModelInspectorBoundary(Protocol):
"""Inspect architecture metadata from a loaded diffusion model."""
def inspect(self, model: object) -> DiffusionModelMetadata | None:
"""Return normalized metadata when ComfyUI exposes it."""
class ClipTypeSupportBoundary(Protocol):
"""Validate that the host supports a required Comfy CLIP type."""
def require(self, clip_type_name: str) -> None:
"""Raise when a required CLIP type is unavailable."""
class AutoModelResolverBoundary(Protocol):
"""Resolve a trusted catalog artifact to a verified local file."""
def resolve(
self,
artifact: AutoModelArtifact,
progress: ProgressReporter | None = None,
) -> AutoModelResolution:
"""Return a verified local artifact path."""
class VaeLoaderBoundary(Protocol):
"""Load one manually selected ComfyUI VAE."""
def load_vae(self, vae_name: str) -> object:
"""Return a loaded ComfyUI VAE object."""
@@ -9,14 +9,14 @@ from __future__ import annotations
import torch
from ..domain.segs import NativeSegs, Segment
from ..masking.mask_components import connected_mask_components
from ..masking.segs_mask_ops import (
from ..domain.segs_mask_ops import (
crop_image,
crop_mask,
crop_region_for_bbox,
dilate_mask,
validate_single_image,
)
from ..masking.mask_components import connected_mask_components
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
@@ -9,13 +9,13 @@ from __future__ import annotations
from typing import Any
from ..domain.prompt_control_prompt import PreparedPromptSide
from ..services.prompt_control_segment_planning_service import (
PromptControlSegmentPlanningService,
)
from .prompt_control_graph_adapter import (
from ..runtime.prompt_control_graph_adapter import (
PromptControlGraphAdapter,
RegionalSegmentEncoding,
)
from .prompt_control_segment_planning_service import (
PromptControlSegmentPlanningService,
)
PROMPT_CONTROL_MISSING_MESSAGE = (
"Encode Prompt Batch w/ Prompt Control requires comfyui-prompt-control. "
@@ -11,14 +11,14 @@ from typing import Any
from ..domain.negative_prompt_weights import contains_negative_prompt_weight
from ..domain.prompt_batch_parser import DEFAULT_PROMPT_BATCH_SEPARATOR
from ..domain.prompt_control_prompt import PreparedPromptSide, apply_encode_style
from ..services.prompt_control_segment_planning_service import (
PromptControlSegmentPlan,
PromptControlSegmentPlanningService,
)
from .prompt_control_graph_adapter import (
from ..runtime.prompt_control_graph_adapter import (
PromptControlGraphAdapter,
RegionalSegmentEncoding,
)
from .prompt_control_segment_planning_service import (
PromptControlSegmentPlan,
PromptControlSegmentPlanningService,
)
PROMPT_CONTROL_MISSING_MESSAGE = (
"Schedule & Encode Prompts requires comfyui-prompt-control. "
@@ -11,7 +11,7 @@ from typing import Protocol
import torch
from ..domain.segs import NativeSegs, Segment, coerce_segment_mask
from ..masking.segs_mask_ops import (
from ..domain.segs_mask_ops import (
crop_image,
crop_mask,
crop_region_for_bbox,
@@ -23,7 +23,7 @@ from ..runtime.ultralytics_detection import (
UltralyticsDetection,
run_ultralytics_detection,
)
from ..runtime.ultralytics_loader import UltralyticsDetectorModel
from ..runtime.ultralytics_model_adapter import UltralyticsDetectorModel
from ..shared.logging import get_logger
from .segs_output_service import combined_mask_from_segs
@@ -14,7 +14,7 @@ import torch
import torch.nn.functional as functional
from ..domain.segs import BoundingBox, CropRegion, NativeSegs, Segment
from ..masking.segs_mask_ops import validate_single_image
from ..domain.segs_mask_ops import validate_single_image
from ..runtime.progress import NullPhaseProgressReporter, PhaseProgressReporter
from ..runtime.sam_automatic_segmenter import (
AutomaticSAMMask,
+1 -1
View File
@@ -21,7 +21,7 @@ from ..domain.segs import (
sort_segs,
to_impact_compatible_segs,
)
from ..masking.segs_mask_ops import (
from ..domain.segs_mask_ops import (
crop_image,
crop_mask,
crop_region_for_bbox,
@@ -6,18 +6,18 @@
from __future__ import annotations
from dataclasses import dataclass
from math import ceil, sqrt
import torch
import torch.nn.functional as functional
from ..domain.seg_preview import AtlasPlacement, SegPreviewDocument, SegPreviewRegion
from ..domain.seg_visualization import (
SegVisualizationPlan,
build_seg_visualization_plan,
)
from ..domain.segs import CropRegion, NativeSegs
from ..masking.segs_mask_ops import validate_single_image
from ..domain.segs import NativeSegs
from ..domain.segs_mask_ops import validate_single_image
_MAX_PREVIEW_EDGE = 1024
_ATLAS_PIXEL_BUDGET = 4 * 1024 * 1024
@@ -25,44 +25,6 @@ _MAX_ATLAS_EDGE = 2048
_ATLAS_PADDING = 1
@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 the 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, ...]
class SimplePreviewSEGSService:
"""Create one compact interactive-preview document without ComfyUI IO."""
@@ -25,7 +25,7 @@ from ..domain.segs import (
coerce_segs,
to_impact_compatible_segs,
)
from ..masking.segs_mask_ops import validate_single_image
from ..domain.segs_mask_ops import validate_single_image
from ..runtime.conditioning_encoding import ComfyConditioningEncoder
from ..runtime.external_llm_images import ExternalLLMSegsImageEncoder
from ..runtime.progress import ProgressReporter, create_comfy_progress
@@ -21,7 +21,7 @@ from ..domain.segs import (
coerce_segs,
to_impact_compatible_segs,
)
from ..masking.segs_mask_ops import crop_image, validate_single_image
from ..domain.segs_mask_ops import crop_image, validate_single_image
from ..runtime.conditioning_encoding import ComfyConditioningEncoder
from ..runtime.loaded_models import LoadedWD14Tagger, unwrap_wd14_tagger
from ..runtime.progress import ProgressReporter, create_comfy_progress
@@ -15,8 +15,8 @@ import torch
from ..domain.conditioning_batch import ConditioningBatch
from ..domain.prompt_composition import prefix_prompt
from ..domain.segs import ImpactSegs, NativeSegs, to_impact_compatible_segs
from ..domain.segs_mask_ops import crop_image, validate_single_image
from ..domain.tile_segs import TileSEGSBuilder, TileSEGSControls
from ..masking.segs_mask_ops import crop_image, validate_single_image
from ..runtime.conditioning_encoding import ComfyConditioningEncoder
from ..runtime.loaded_models import LoadedWD14Tagger, unwrap_wd14_tagger
from ..runtime.progress import ProgressReporter, create_comfy_progress
@@ -0,0 +1,111 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Adapt detail encode/decode behavior to tiled diffusion sampling."""
from __future__ import annotations
from typing import Any, Protocol
import torch
from ..runtime.detail_previews import DetailPreviewContext
from ..runtime.detail_sampling import DetailSampler, Latent
from .tiled_diffusion_sampling_service import TiledDiffusionSamplingService
class TiledDiffusionLatentSamplingBoundary(Protocol):
"""Latent sampling boundary for selectable tiled diffusion modes."""
def sample(
self,
*,
diffusion_mode: str,
model: Any,
seed: int,
steps: int,
cfg: float,
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: Latent,
denoise: float,
latent_tile_width: int,
latent_tile_height: int,
latent_tile_overlap: int,
latent_tile_batch_size: int,
preview_context: DetailPreviewContext | None = None,
differential_diffusion: bool = False,
) -> Latent:
"""Sample a latent using the selected tiled diffusion mode."""
class TiledDetailSampler:
"""Adapt shared VAE helpers and tiled diffusion application services."""
def __init__(
self,
detail_sampler: DetailSampler | None = None,
tiled_sampling_service: TiledDiffusionLatentSamplingBoundary | None = None,
) -> None:
"""Create the adapter with injectable sampling collaborators."""
self._detail_sampler = detail_sampler or DetailSampler()
self._tiled_sampling_service = (
tiled_sampling_service or TiledDiffusionSamplingService()
)
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_tiled(
self,
*,
diffusion_mode: str,
model: Any,
seed: int,
steps: int,
cfg: float,
sampler_name: str,
scheduler: str,
positive: Any,
negative: Any,
latent_image: Latent,
denoise: float,
latent_tile_width: int,
latent_tile_height: int,
latent_tile_overlap: int,
latent_tile_batch_size: int,
preview_context: DetailPreviewContext | None = None,
differential_diffusion: bool = False,
) -> Latent:
"""Sample one latent crop with the selected tiled diffusion runtime."""
return self._tiled_sampling_service.sample(
diffusion_mode=diffusion_mode,
model=model,
seed=seed,
steps=steps,
cfg=cfg,
sampler_name=sampler_name,
scheduler=scheduler,
positive=positive,
negative=negative,
latent_image=latent_image,
denoise=denoise,
latent_tile_width=latent_tile_width,
latent_tile_height=latent_tile_height,
latent_tile_overlap=latent_tile_overlap,
latent_tile_batch_size=latent_tile_batch_size,
preview_context=preview_context,
differential_diffusion=differential_diffusion,
)
@@ -0,0 +1,264 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Orchestrate Ultralytics discovery, download, loading, and compatibility."""
from __future__ import annotations
from collections.abc import MutableMapping
from dataclasses import dataclass
from pathlib import Path
from types import ModuleType
from ..runtime.model_catalog import ULTRALYTICS_ENTRIES, ModelEntry
from ..runtime.model_choices import ModelChoiceService
from ..runtime.model_downloads import DownloadRequest, ModelDownloader, ProgressReporter
from ..runtime.model_folders import expected_model_file, resolve_model_file
from ..runtime.model_instance_cache import ModelInstanceCache
from ..runtime.ultralytics_model_adapter import (
UltralyticsDetectorModel,
UltralyticsModelAdapter,
)
from ..runtime.ultralytics_model_folders import (
ULTRALYTICS_BBOX_FOLDER,
ULTRALYTICS_SEGM_FOLDER,
UltralyticsModelFolders,
)
from ..shared.logging import get_logger
from .detector_compat import BBoxDetectorFacade, SegmDetectorFacade
LOGGER = get_logger(__name__)
NO_LOCAL_ULTRALYTICS_MODELS = "No local Ultralytics models found"
@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:
"""Coordinate model selection with runtime adapters and detector facades."""
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,
model_folders: UltralyticsModelFolders | None = None,
model_adapter: UltralyticsModelAdapter | None = None,
) -> None:
"""Create the service with injectable runtime boundaries."""
self._model_folders = model_folders or UltralyticsModelFolders(
folder_paths_module
)
self._model_adapter = model_adapter or UltralyticsModelAdapter(
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 followed by downloadable catalog choices."""
self._model_folders.register()
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 model choices discovered by the runtime folder adapter."""
return self._model_folders.available_models()
def load(
self,
model_name: str,
progress: ProgressReporter | None = None,
) -> LoadedUltralyticsDetector:
"""Load one model and construct its workflow-compatible 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 = model_name.replace("\\", "/")
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 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 selection through the folder adapter."""
return self._model_folders.resolve(model_name)
def _resolve_catalog_entry(
self,
entry: ModelEntry,
progress: ProgressReporter | None,
) -> Path:
"""Resolve or securely download one curated detector checkpoint."""
if len(entry.artifacts) != 1:
raise RuntimeError(
f"Ultralytics catalog entry '{entry.entry_id}' must have one artifact."
)
self._model_folders.register()
artifact = entry.artifacts[0]
folder_paths_module = self._model_folders.folder_paths_module
existing = resolve_model_file(
artifact.folder_name,
artifact.filename,
folder_paths_module,
)
destination = existing or expected_model_file(
artifact.folder_name,
artifact.filename,
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 a native model and construct both compatibility facades."""
detector_model = self._model_adapter.load(model_name, model_path)
bbox_detector = BBoxDetectorFacade(detector_model)
segm_detector = SegmDetectorFacade(detector_model, bbox_detector)
return LoadedUltralyticsDetector(
detector_model=detector_model,
bbox_detector=bbox_detector,
segm_detector=segm_detector,
)
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}"
+100
View File
@@ -0,0 +1,100 @@
# AGENTS.md
## Scope
This file supplements the repository-root `AGENTS.md` for every file below
`tests/`.
## Mission
The test suite provides fast, deterministic, behaviorally meaningful evidence
for SimpleSyrup's ComfyUI nodes, domain behavior, services, runtime adapters,
tooling, and persisted workflow contracts.
## Ownership And Placement
- Organize tests by product capability or authoritative behavior owner.
- Do not add test modules directly under `tests/`; the root is reserved for
pytest configuration and execution policy.
- Within a large capability, split tests by domain, service, runtime, node, or
integration boundary when those owners change independently.
- Keep capability-specific fixtures, fakes, values, and harnesses in that
capability's `support/` package.
- Promote support to `tests/support/` only when independent capabilities use
the same stable testing contract.
- Do not create generic helper, common, misc, or utility dumping grounds.
- Keep `web/tests/` organized by the same capabilities as `web/src/`.
- Update policy entries, runner inventories, imports, and focused-test paths in
the same change as any test move.
## Behavioral Proof
- Test observable behavior through its authoritative owner.
- Prefer the lightest real component that proves the complete contract.
- Do not mock the behavior under test or duplicate production rules in expected
result implementations.
- Cover relevant success, failure, boundary, cancellation, cleanup, and
regression paths.
- System and live-Comfy tests must prove composition that focused owner tests
cannot prove.
## Parallelism And Isolation
- Tests are parallel-safe by default.
- Do not branch behavior on `PYTEST_XDIST_WORKER`.
- Do not serialize tests to hide leaked state, fixed resources, nondeterminism,
or unsafe cleanup.
- Use fresh-process isolation only for a demonstrated process-lifetime
constraint.
- Use serial execution only for an exact global or external resource that
cannot safely overlap.
- Record every isolated or serial module in `tests/ci_test_policy.py` with a
reviewed governance disposition.
## Determinism
- Control clocks, timers, randomness, environment, filesystems, subprocesses,
network responses, and external Comfy state when they affect behavior.
- Do not use arbitrary sleeps as completion conditions.
- Wait for observable state with bounded diagnostic timeouts.
- Do not use retries, skips, weakened assertions, or increased delays to hide
flakes.
- Every subprocess and network operation must have an explicit failure bound
and guaranteed cleanup.
## Test State
- Use `tmp_path` and `pathlib.Path` for filesystem behavior.
- Do not write test artifacts into the repository unless the artifact path is
itself the contract under test.
- Restore environment variables, module replacements, registries, logging
handlers, working directories, and global settings.
- Keep autouse fixtures limited to universal safety and cleanup invariants.
- Give every fixture one cohesive lifecycle and explicit typed result.
## Typing And Maintainability
- Type tests, fixtures, fakes, builders, and harness APIs.
- Do not add test-only `.pyi` files that shadow executable modules.
- Use explicit protocols or focused typed fakes at dynamic boundaries.
- Keep setup and assertions readable at the test callsite.
- Shared abstractions must reduce repeated change risk without hiding the
behavior being proved.
## Governance
- Run `..\..\venv\Scripts\python.exe -m tools.check_test_governance`
after changing test placement, timing, isolation, resources, or execution
policy.
- Every discovered candidate requires source-level review.
- A classification waiver records legitimate intentional behavior.
- Inappropriate current design requires debt plus an exact remediation waiver.
- Reviewed state must remain fingerprinted, expiring, and file-specific.
## Verification
- Run focused tests continuously while changing a capability.
- Run collection after moving tests.
- Run the full parallel suite before completion.
- Run architecture governance, test governance, formatting, lint, strict
typing, and frontend gates when applicable.
+10
View File
@@ -0,0 +1,10 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Declare reviewed process-isolated and serial test modules."""
from __future__ import annotations
ISOLATED_TEST_MODULES: tuple[str, ...] = ()
SERIAL_TEST_MODULES: tuple[str, ...] = ()
+5
View File
@@ -0,0 +1,5 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own comfy integration test behavior."""
@@ -12,17 +12,20 @@ import pytest
from tools.comfy_integration.loopback_port import (
is_loopback_port_available,
select_unused_loopback_port,
reserve_loopback_port,
validate_loopback_port,
)
def test_selected_port_is_nondefault_and_unused() -> None:
"""Prove selection returns a bindable non-default loopback port."""
def test_reserved_port_remains_exclusive_until_owner_releases_it() -> None:
"""Keep the OS-assigned port unavailable throughout reservation ownership."""
port = select_unused_loopback_port()
with reserve_loopback_port() as reservation:
port = reservation.port
assert port not in {8188, 8297}
assert not is_loopback_port_available(port)
assert port not in {8188, 8297}
assert is_loopback_port_available(port)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
probe.bind(("127.0.0.1", port))
assert not is_loopback_port_available(port)
@@ -52,6 +52,21 @@ class _FakeClient:
self.base_url = base_url
class _FakeReservation:
"""Expose one deterministic candidate port and release observation."""
def __init__(self, port: int) -> None:
"""Retain the selected fake port."""
self.port = port
self.release_calls = 0
def release(self) -> None:
"""Record the handoff immediately before process launch."""
self.release_calls += 1
def _configure(
monkeypatch: pytest.MonkeyPatch,
process: _FakeProcess,
@@ -60,7 +75,8 @@ def _configure(
) -> None:
"""Replace external lifecycle boundaries with deterministic fakes."""
monkeypatch.setattr(managed_server, "select_unused_loopback_port", lambda: 8299)
reservation = _FakeReservation(8299)
monkeypatch.setattr(managed_server, "reserve_loopback_port", lambda: reservation)
monkeypatch.setattr(
"tools.comfy_integration.managed_server.WindowsComfyProcess.start",
lambda command, stdout_path, stderr_path: process,
@@ -80,6 +96,61 @@ def _configure(
monkeypatch.setattr(managed_server, "wait_for_server", ready)
def test_port_collision_retries_with_new_owned_process(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Retry only after cleanup proves another process claimed the candidate port."""
reservations = [_FakeReservation(8299), _FakeReservation(8300)]
first_reservation, second_reservation = reservations
processes = [_FakeProcess(), _FakeProcess()]
monkeypatch.setattr(
managed_server,
"reserve_loopback_port",
lambda: reservations.pop(0),
)
monkeypatch.setattr(
"tools.comfy_integration.managed_server.WindowsComfyProcess.start",
lambda command, stdout_path, stderr_path: processes.pop(0),
)
monkeypatch.setattr(managed_server, "LoopbackComfyClient", _FakeClient)
readiness_calls = 0
def ready(
client: object, live: object, required: object, *, timeout: float
) -> JsonObject:
"""Fail the collided attempt and accept the replacement."""
nonlocal readiness_calls
del client, live, required, timeout
readiness_calls += 1
if readiness_calls == 1:
raise RuntimeError("Managed Comfy process exited before readiness.")
return {"ready": True}
monkeypatch.setattr(managed_server, "wait_for_server", ready)
availability = iter((False,))
monkeypatch.setattr(
managed_server,
"is_loopback_port_available",
lambda _port: next(availability),
)
first, second = processes
with ManagedComfyServer(
comfy_root=Path("<COMFY_ROOT>"),
artifacts=IntegrationArtifacts(tmp_path),
required_node_ids=frozenset(),
) as running:
assert running.port == 8300
assert first.stop_calls == 1
assert second.stop_calls == 1
assert first_reservation.release_calls == 1
assert second_reservation.release_calls == 1
assert readiness_calls == 2
def test_context_stops_exact_created_process_after_body_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:

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