Compare commits

...
130 Commits
Author SHA1 Message Date
Daisy 05667f6517 chore(release): 1.13.0 [skip ci]
# [1.13.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.12.0...v1.13.0) (2026-10-02)

### Bug Fixes

* **negpip:** support Krea attention on ComfyUI 0.28 ([e4eabfe](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/e4eabfefd540f3a6066c29761cefb8f8b3c80f68))

### Features

* **sampling:** add noise inversion and composable sampler options ([be4bd9b](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/be4bd9bb162f6ed4252e0c4d4ef0f3efebe8c674))
* **sampling:** refine sampler options and inversion controls ([ab7cebc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/ab7cebcebc4d5dfa95aa8d834456af1778fe56a6))
2026-10-02 17:16:58 +00:00
Artificial Sweetener ab7cebcebc feat(sampling): refine sampler options and inversion controls 2026-10-02 12:51:27 -04:00
Artificial Sweetener e4eabfefd5 fix(negpip): support Krea attention on ComfyUI 0.28 2026-09-30 21:48:07 -04:00
Artificial Sweetener bbe86060c6 chore(licensing): complete inversion source and test headers 2026-09-30 21:48:07 -04:00
Artificial Sweetener be4bd9bb16 feat(sampling): add noise inversion and composable sampler options 2026-09-30 20:50:48 -04:00
Daisy 913cc7ba55 chore(release): 1.12.0 [skip ci]
# [1.12.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.1...v1.12.0) (2026-09-29)

### Bug Fixes

* **release:** satisfy RES4LYF publication contracts ([04dc35a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/04dc35af00163689509cace1541a8e7d33b5053e))

### Features

* **sampling:** add RES4LYF sampler methods and schedules ([7b1efef](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/7b1efef47e20b9bf6f032dd57971899d99881822))
2026-09-29 17:24:20 +00:00
Artificial Sweetener 04dc35af00 fix(release): satisfy RES4LYF publication contracts 2026-09-29 13:16:28 -04:00
Artificial Sweetener 7b1efef47e feat(sampling): add RES4LYF sampler methods and schedules 2026-09-29 13:03:55 -04:00
Daisy ec49b685db chore(release): 1.11.1 [skip ci]
## [1.11.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.0...v1.11.1) (2026-09-25)

### Bug Fixes

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

### Features

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

### Bug Fixes

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

### Features

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

### Bug Fixes

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

### Bug Fixes

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

### Bug Fixes

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

### Features

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

### Bug Fixes

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

### Features

* **models:** add curated ultralytics downloads ([b907fa2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b907fa2a17afa30170bc22cf1134a750241e55c2))
* **models:** prioritize installed ultralytics choices ([d30e04f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d30e04f229366d3e1d2388bd4d713b2b47a12a1f))
2026-09-19 16:39:28 +00:00
Artificial Sweetener dcc37d7ab6 fix(downloads): keep unknown sizes indeterminate 2026-09-19 10:48:43 -04:00
Artificial Sweetener 583b22a4bf feat(models): prioritize installed ultralytics choices 2026-09-19 10:32:49 -04:00
Artificial Sweetener ebe01efc49 fix(models): hide installed catalog choices 2026-09-19 00:37:32 -04:00
Artificial Sweetener 41e8a2b61c feat(models): add curated ultralytics downloads 2026-09-19 00:25:58 -04:00
Daisy 22e4a5d202 chore(release): 1.7.1 [skip ci]
## [1.7.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.7.0...v1.7.1) (2026-09-11)

### Bug Fixes

* **regional:** preserve shared model patch ancestry ([6059a3f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6059a3f913a9502671666e83faeb8686a7a8da27))
2026-09-11 15:12:46 +00:00
Artificial Sweetener 017e3fc7fe fix(regional): preserve shared model patch ancestry
Keep compatible parallel regional paths on one inherited model lineage, including NegPip interoperability, while retaining bounded fused and optional Triton execution paths.

Expand graph-shape, lifecycle, memory-safety, and runtime regressions across the supported attention families.
2026-09-10 23:11:24 -04:00
Daisy c298efd8e8 chore(release): 1.7.0 [skip ci]
# [1.7.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.6.0...v1.7.0) (2026-09-05)

### Bug Fixes

* **anima:** support regional prompting across Comfy versions ([41a234a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/41a234a85a4cfcdc4cfce68b80b4b9982719aab4))
* **attention:** preserve anchored concept geometry ([1136efd](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/1136efd2ad8708b14320d04eab2f6489efbec282))
* **cache:** make integer narrowing checker-independent ([2547767](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/254776791766c41c75a69ceb5b207c54949446c9))
* **detailers:** align SEGS mask blending behavior ([a3120ae](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a3120aebe8834982706d93784a39ab501c6ff40a))
* **groundingdino:** support transformers v4 and v5 ([3387c03](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/3387c03eb59f244d56f6a0dfe86cedab1847a8e2))
* **mask:** preserve missing-alpha image geometry ([caf7d37](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/caf7d37a154ddc62405807b56550efdaa831d09e))
* **media:** stabilize native ordered preview controls ([1235652](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/123565225a1104234a7c9f5c43af0772c7508db8))
* **regional:** align prompt batches and LoRA hooks ([656d197](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/656d1970e12be18304b057b07204dcbbe367432b))
* **runtime:** centralize Comfy patcher lifecycle ([0e5f513](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0e5f513ae0f33f40c6a8bd09161043a5af598392))
* **sampling:** normalize model-specific latent layouts ([b2084a7](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b2084a7a9bf04709af51380bc1ef4a09ccb9babc))
* **tiled-diffusion:** clamp overlap for small latents ([fecba36](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fecba36e4e11f0da681c6a5d9d42e18093d741fc))
* **tools:** return host-native checkpoint selections ([c7cb8d2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/c7cb8d29f83ebd44b48038b7ce5e65a0a6f445b4))
* use SimpleSyrup package identity ([1659d13](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/1659d131f4215fa0baccc4c70024d63590460e67))

### Features

* **anima:** add cached quantization profiles ([c20664f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/c20664f8925305465ccb4c028d0a78a6364a037d))
* **attention:** add sampler-derived concept regions ([8ca5d2c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/8ca5d2c325e1caee822883ba568b25f721e51343))
* **attention:** default regional prompts to full weight ([f829321](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f82932193951bae75d59e1c7c7dba2d187e3c175))
* **attention:** improve concept isolation fidelity and speed ([01826ad](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/01826ad1b7c64b11c8af031691403ecf521cb1b5))
* **attention:** refine attention-derived region masks ([dff84cc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/dff84cce2322e64b8a9ada91b84550faf3a5c7a1))
* **conditioning:** add regional prompting and SEP-local LoRAs ([d3de028](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d3de02816b24bea129e77b82568ad47a0fd0ba99))
* **conditioning:** support labeled prompt separators ([a708e0b](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a708e0b4187b0d5f9aeb58f5ab0d276b8a046395))
* **detailing:** add external llm segs tagging ([207c449](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/207c4492908371f41aca84c74332c1a1f63d8045))
* **detection:** add keep-only SEGS selection ([4f65ae0](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4f65ae0dd36e43347b40ae64039f40e8a67aea48))
* initial release ([4b6525c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4b6525ce6ff42f06a7ffd48a54186fcb625f0e21))
* **loaders:** add automatic FLUX model loaders ([33bff75](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/33bff75af1b001b716a45f4fadc9e0e0aa20ced1))
* **masking:** expand segmentation tooling and progress ([adba198](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/adba1981a4bdb43307496a84cccbfe100ae2174f))
* **media:** add native ordered loaders and SEGS preview ([fcf38f2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fcf38f2010cfadc76be74410857387c74db3b575))
* **nodes:** add VAE options and clone-safe diffusion ([5fc0f3d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/5fc0f3d8e5ef5fbac0306b1d3c70464a035c396c))
* **prompt-control:** add schedule and encode prompt node ([af32cec](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/af32cec99f5bdadcbed9f8c33db0d816ad4b72f0))
* **regional:** add native SDXL adapter execution ([068e3db](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/068e3db17d13c875a383c85e6cf931f96459c3b1))
* **regional:** add universal attention coupling foundation ([edfc26c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/edfc26c9122ff0c18d63e9b80832d587594e9e62))
* **regional:** build universal adapter execution foundation ([864852d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/864852dc9e591a3041e23b342346495a7fbf6589))
* **regional:** complete capability-routed execution ([fe96a20](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fe96a206dbf1edcae022b1346f998ed184142062))
* **regional:** complete persistent regional LoRA execution ([7b5987d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/7b5987d6fa66f8deee2655d18b1209bbe20271d6))
* **sampling:** add contextual diffusion sampler ([24bf630](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/24bf6309023c552f65947814d9641555a73c8337))
* **sampling:** add deterministic seed variation ([7921be3](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/7921be3f9067a26fe5fa47fb8fb370e7cb1679f3))
* **sampling:** add regional diffusion sampling ([f7dffca](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f7dffcacfe104be173793ce412f994519e11e02e))
* **sampling:** bypass inactive attention coupling ([b2b8dc4](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b2b8dc4b016307fd351892f50264c64532898681))
* **sampling:** expose evaluated context SEGS ([04a2c3e](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/04a2c3e6e90f9bbb9e3922412844afe5a4e6869f))
* **segmentation:** add interactive SEGS preview ([c9e303e](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/c9e303ec4727576af124d9e8ea20d0103f67e7ae))
* **segmentation:** add SAM region overlay ([a72c796](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a72c796d8d6d64a52fed7d281fe53eebc74639ed))
* **segmentation:** add SAM-guided tiled diffusion ([968090d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/968090de87a4fad726fd37d0a08966c01f11a8fd))
* **segs:** add regional batching and wd14 tagging nodes ([600d9e3](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/600d9e311b5b8c45762e15c88f54df33454261b5))
2026-09-05 23:00:23 +00:00
Artificial Sweetener 7b92a4dafc fix(tools): return host-native checkpoint selections 2026-09-05 18:54:09 -04:00
Artificial Sweetener ba13ef3345 test(integration): make clean-runner suite portable 2026-09-05 18:46:23 -04:00
Artificial Sweetener dfa00d04b6 ci(types): make Windows process constants portable 2026-09-05 18:35:42 -04:00
Artificial Sweetener f4e33ff1c7 ci(lint): stabilize first-party import classification 2026-09-05 18:25:59 -04:00
Artificial Sweetener 35ffc8b0e3 ci(release): isolate compatibility tests from Comfy fixtures 2026-09-05 18:18:57 -04:00
Artificial Sweetener 9f4dc7a193 chore(repository): reconcile canonical release history 2026-09-05 18:14:46 -04:00
Artificial Sweetener 1e0f44236e fix(anima): support regional prompting across Comfy versions 2026-09-05 18:13:59 -04:00
Artificial Sweetener af6913e7bf chore(observability): log detail segment resource usage 2026-09-05 16:04:54 -04:00
Artificial Sweetener b73d3c570f feat(attention): default regional prompts to full weight 2026-08-30 21:01:11 -04:00
Artificial Sweetener c35191de31 fix(sampling): normalize model-specific latent layouts 2026-08-30 21:00:58 -04:00
Artificial Sweetener e8cbc6f724 feat(sampling): add deterministic seed variation 2026-08-30 21:00:45 -04:00
Artificial Sweetener 4c13682df3 feat(sampling): bypass inactive attention coupling 2026-08-30 18:42:12 -04:00
Artificial Sweetener ffee87974e feat(attention): refine attention-derived region masks 2026-08-30 17:47:50 -04:00
Artificial Sweetener 4f0d683c56 fix(attention): preserve anchored concept geometry 2026-08-26 23:04:03 -04:00
Artificial Sweetener 837a675a13 feat(attention): improve concept isolation fidelity and speed 2026-08-26 22:09:36 -04:00
Artificial Sweetener 02710d2544 feat(attention): add sampler-derived concept regions 2026-08-26 18:43:33 -04:00
Artificial Sweetener b80d2bc420 refactor(tools): remove machine-specific defaults 2026-08-17 17:12:30 -04:00
Artificial Sweetener d9b92d65b1 feat(regional): complete capability-routed execution 2026-08-17 01:35:50 -04:00
Artificial Sweetener 24878eef89 refactor(regional): reduce standard UNet cold startup 2026-08-16 20:53:50 -04:00
Artificial Sweetener 9de6033505 feat(regional): complete persistent regional LoRA execution 2026-08-16 16:53:30 -04:00
Artificial Sweetener 36227fa8ff feat(regional): add native SDXL adapter execution 2026-08-13 01:28:43 -04:00
Artificial Sweetener 4875afdb7e feat(regional): build universal adapter execution foundation 2026-08-12 22:49:35 -04:00
Artificial Sweetener dda0a3d326 feat(regional): add universal attention coupling foundation 2026-08-12 19:24:26 -04:00
Artificial Sweetener ace5fafda9 feat(sampling): add regional diffusion sampling 2026-08-10 00:55:03 -04:00
Artificial Sweetener f031c28589 feat(anima): add cached quantization profiles 2026-08-10 00:47:13 -04:00
Daisy e9cd06e41c chore(release): 1.6.0 [skip ci]
# [1.6.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.5.0...v1.6.0) (2026-08-09)

### Bug Fixes

* **cache:** make integer narrowing checker-independent ([6e2d1e1](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6e2d1e1844b361f9b2f3a31c8538cfde0ce7c6b1))
* **mask:** preserve missing-alpha image geometry ([70ffeb5](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/70ffeb530acb1f5e55ac6e06adcc07ba4560d776))
* **media:** stabilize native ordered preview controls ([3d03b1d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/3d03b1dfbc2eef3cee900173ddda57e03d5bc43d))
* **regional:** align prompt batches and LoRA hooks ([646e4e7](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/646e4e7cab0ed299e704c4acbf64849181180076))
* **runtime:** centralize Comfy patcher lifecycle ([fda2ef4](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fda2ef4074e8550a406ada317f0a3bfb7db39cf6))

### Features

* **conditioning:** add regional prompting and SEP-local LoRAs ([be26493](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/be264935a8cb0e805222de9d615a197a788d8f01))
* **conditioning:** support labeled prompt separators ([a24da13](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a24da1359cc9966762d8e1fa4fde3dbdef879cfa))
* **loaders:** add automatic FLUX model loaders ([08fd18c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/08fd18cc6fdf6a7f9ff3657c323b31e8960a235a))
* **media:** add native ordered loaders and SEGS preview ([dbde2a9](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/dbde2a9266dd043b92126e42e502cc88d9fba777))
* **sampling:** add contextual diffusion sampler ([6c2e2da](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6c2e2dad9c6db42a2a2a82d1e4a17e4db4bb0fbf))
* **sampling:** expose evaluated context SEGS ([53a2771](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/53a2771ce815a0705b92f4766e56f11adbd9dedb))
* **segmentation:** add interactive SEGS preview ([21db62d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/21db62d3acee0b1d087f83e01fb5b92c912f4df6))
* **segmentation:** add SAM region overlay ([d1ead71](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d1ead71ca714e365bf27448170e6c8c8fccd2f5a))
* **segmentation:** add SAM-guided tiled diffusion ([e50ec0e](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/e50ec0ea79620af63f32c25a934b2b06fc4d9484))
2026-08-09 05:44:42 +00:00
Artificial Sweetener fc738179f3 fix(cache): make integer narrowing checker-independent 2026-08-09 01:39:24 -04:00
Artificial Sweetener 2dc1331262 fix(cache): make integer narrowing checker-independent 2026-08-09 01:39:24 -04:00
Artificial Sweetener 64ac708ba0 docs(readme): update project overview 2026-08-09 01:21:19 -04:00
Artificial Sweetener 60da3c1a48 docs(readme): update project overview 2026-08-09 01:21:19 -04:00
Artificial Sweetener 4f4c47ae0a fix(runtime): centralize Comfy patcher lifecycle 2026-08-08 10:35:24 -04:00
Artificial Sweetener 8104e81a29 fix(runtime): centralize Comfy patcher lifecycle 2026-08-08 10:35:24 -04:00
Artificial Sweetener e1d051781a feat(loaders): add automatic FLUX model loaders 2026-08-07 20:51:18 -04:00
Artificial Sweetener 4a6b8eb15d feat(loaders): add automatic FLUX model loaders 2026-08-07 20:51:18 -04:00
Artificial Sweetener e266880244 feat(conditioning): support labeled prompt separators 2026-08-05 22:19:50 -04:00
Artificial Sweetener 02784b1ebe feat(conditioning): support labeled prompt separators 2026-08-05 22:19:50 -04:00
Artificial Sweetener 44cc18b86c chore(standards): require component-owned frontend elements 2026-08-04 21:42:19 -04:00
Artificial Sweetener 4a61946919 chore(standards): require component-owned frontend elements 2026-08-04 21:42:19 -04:00
Artificial Sweetener 18b7a72ddf test(media): capture native preview verification 2026-08-04 21:42:03 -04:00
Artificial Sweetener 18097c8fae fix(mask): preserve missing-alpha image geometry 2026-08-04 21:41:46 -04:00
Artificial Sweetener 2e5e14f6c2 fix(mask): preserve missing-alpha image geometry 2026-08-04 21:41:46 -04:00
Artificial Sweetener cab3cac8f7 fix(media): stabilize native ordered preview controls 2026-08-04 21:41:32 -04:00
Artificial Sweetener be348aeeda fix(media): stabilize native ordered preview controls 2026-08-04 21:41:32 -04:00
Artificial Sweetener 13a70540fd feat(media): add native ordered loaders and SEGS preview 2026-08-03 21:59:26 -04:00
Artificial Sweetener dce4327f9e feat(media): add native ordered loaders and SEGS preview 2026-08-03 21:59:26 -04:00
Artificial Sweetener 652ae51fc4 feat(sampling): expose evaluated context SEGS 2026-08-03 21:55:16 -04:00
Artificial Sweetener 667073a673 feat(sampling): expose evaluated context SEGS 2026-08-03 21:55:16 -04:00
Artificial Sweetener 823fe209d8 feat(segmentation): add interactive SEGS preview 2026-08-02 02:43:27 -04:00
Artificial Sweetener 6a6c3f8362 feat(segmentation): add interactive SEGS preview 2026-08-02 02:43:27 -04:00
Artificial Sweetener 1972e452dc feat(segmentation): add SAM region overlay 2026-08-02 02:43:27 -04:00
Artificial Sweetener 4241d7e97c feat(segmentation): add SAM region overlay 2026-08-02 02:43:27 -04:00
Artificial Sweetener 29ee772b1d feat(sampling): add contextual diffusion sampler 2026-08-02 02:43:26 -04:00
Artificial Sweetener b58539097a feat(sampling): add contextual diffusion sampler 2026-08-02 02:43:26 -04:00
Artificial Sweetener 24492d97a3 feat(segmentation): add SAM-guided tiled diffusion 2026-07-31 22:08:15 -04:00
Artificial Sweetener 36081d7771 feat(segmentation): add SAM-guided tiled diffusion 2026-07-31 22:08:15 -04:00
Artificial Sweetener 7156dff28f fix(regional): align prompt batches and LoRA hooks 2026-07-31 22:05:57 -04:00
Artificial Sweetener 546ee5db01 fix(regional): align prompt batches and LoRA hooks 2026-07-31 22:05:57 -04:00
Artificial Sweetener d9e20fc601 feat(conditioning): add regional prompting and SEP-local LoRAs 2026-07-31 22:05:57 -04:00
Artificial Sweetener 250ee42ea1 feat(conditioning): add regional prompting and SEP-local LoRAs 2026-07-31 22:05:57 -04:00
Daisy 74f140b2f3 chore(release): 1.5.0 [skip ci]
# [1.5.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.4.0...v1.5.0) (2026-07-14)

### Bug Fixes

* **groundingdino:** support transformers v4 and v5 ([3387c03](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/3387c03eb59f244d56f6a0dfe86cedab1847a8e2))

### Features

* **masking:** expand segmentation tooling and progress ([adba198](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/adba1981a4bdb43307496a84cccbfe100ae2174f))
2026-07-14 01:14:17 +00:00
Daisy 74f68ba05a chore(release): 1.5.0 [skip ci]
# [1.5.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.4.0...v1.5.0) (2026-07-14)

### Bug Fixes

* **groundingdino:** support transformers v4 and v5 ([239070b](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/239070b12d3eecbfd5c47c9410c7ca31ac1402ac))

### Features

* **masking:** expand segmentation tooling and progress ([f70766d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f70766dafe096895ad8d6309681fd59270664600))
2026-07-14 01:14:17 +00:00
Artificial Sweetener 9b9f7018fa ci(release): isolate transformers compatibility tests 2026-07-13 21:09:25 -04:00
Artificial Sweetener 009e8b4821 ci(release): isolate transformers compatibility tests 2026-07-13 21:09:25 -04:00
Artificial Sweetener 741b5661c6 ci(release): install host dependency for compatibility tests 2026-07-13 21:05:44 -04:00
Artificial Sweetener 4afd112399 ci(release): install host dependency for compatibility tests 2026-07-13 21:05:44 -04:00
Artificial Sweetener 493eeb94b9 fix(groundingdino): support transformers v4 and v5 2026-07-13 20:54:00 -04:00
Artificial Sweetener f1fef914e3 fix(groundingdino): support transformers v4 and v5 2026-07-13 20:54:00 -04:00
Artificial Sweetener abd63b6296 feat(masking): expand segmentation tooling and progress 2026-07-11 16:25:05 -04:00
Artificial Sweetener 30381c41f8 feat(masking): expand segmentation tooling and progress 2026-07-11 16:25:05 -04:00
Artificial Sweetener 4e3fd80bb8 docs(readme): document exported nodes and settings 2026-06-06 15:54:14 -04:00
Artificial Sweetener 41c0f4615d docs(readme): document exported nodes and settings 2026-06-06 15:54:14 -04:00
Artificial Sweetener 6470b439b5 docs(readme): add project badges 2026-06-06 14:35:41 -04:00
Artificial Sweetener 60fff8e9b8 docs(readme): add project badges 2026-06-06 14:35:41 -04:00
Daisy 28bb8f7e88 chore(release): 1.4.0 [skip ci]
# [1.4.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.3.0...v1.4.0) (2026-06-02)

### Bug Fixes

* **tiled-diffusion:** clamp overlap for small latents ([fecba36](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fecba36e4e11f0da681c6a5d9d42e18093d741fc))

### Features

* **detailing:** add external llm segs tagging ([207c449](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/207c4492908371f41aca84c74332c1a1f63d8045))
* **segs:** add regional batching and wd14 tagging nodes ([600d9e3](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/600d9e311b5b8c45762e15c88f54df33454261b5))
2026-06-02 15:36:25 +00:00
Daisy 561ba3409f chore(release): 1.4.0 [skip ci]
# [1.4.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.3.0...v1.4.0) (2026-06-02)

### Bug Fixes

* **tiled-diffusion:** clamp overlap for small latents ([d7448c6](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d7448c6ca52ce517b8d0f8ee697249c5def13535))

### Features

* **detailing:** add external llm segs tagging ([b7cd40c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b7cd40c85ae9f9514d0b1ff3c3f6007730796752))
* **segs:** add regional batching and wd14 tagging nodes ([fef60ad](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fef60adeca2c8ec7c0641e8106bb0863ee2f195e))
2026-06-02 15:36:25 +00:00
Artificial Sweetener 92c1992379 fix(tiled-diffusion): clamp overlap for small latents 2026-06-02 11:25:53 -04:00
Artificial Sweetener 6cf67e98aa fix(tiled-diffusion): clamp overlap for small latents 2026-06-02 11:25:53 -04:00
Artificial Sweetener acd668f7c6 feat(detailing): add external llm segs tagging 2026-05-31 16:07:50 -04:00
Artificial Sweetener b324f132d8 feat(detailing): add external llm segs tagging 2026-05-31 16:07:50 -04:00
Artificial Sweetener 4c7f087ce5 refactor(exports): use v3-only node registration 2026-05-31 14:25:48 -04:00
Artificial Sweetener c84578d9f9 refactor(exports): use v3-only node registration 2026-05-31 14:25:48 -04:00
Artificial Sweetener c0476b8288 feat(segs): add regional batching and wd14 tagging nodes 2026-05-30 21:50:35 -04:00
Artificial Sweetener ae2dbc7e1a feat(segs): add regional batching and wd14 tagging nodes 2026-05-30 21:50:35 -04:00
Daisy b14f997145 chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.2.0...v1.3.0) (2026-05-26)

### Features

* **prompt-control:** add schedule and encode prompt node ([af32cec](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/af32cec99f5bdadcbed9f8c33db0d816ad4b72f0))
2026-05-26 21:48:45 +00:00
Daisy 8e553f5662 chore(release): 1.3.0 [skip ci]
# [1.3.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.2.0...v1.3.0) (2026-05-26)

### Features

* **prompt-control:** add schedule and encode prompt node ([bd515e6](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/bd515e696cedcc78d056af6c23b9193e34f131bc))
2026-05-26 21:48:45 +00:00
Artificial Sweetener 346ff8b7c4 feat(prompt-control): add schedule and encode prompt node
Add Prompt-Control prompt parsing, lazy graph expansion, legacy and v3 node exports, and batch-aware conditioning support for KSampler Extras and tiled diffusion.
2026-05-26 17:45:21 -04:00
Artificial Sweetener 375d603a8e feat(prompt-control): add schedule and encode prompt node
Add Prompt-Control prompt parsing, lazy graph expansion, legacy and v3 node exports, and batch-aware conditioning support for KSampler Extras and tiled diffusion.
2026-05-26 17:45:21 -04:00
Daisy 92c53b3493 chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.1.0...v1.2.0) (2026-05-25)

### Features

* **nodes:** add VAE options and clone-safe diffusion ([5fc0f3d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/5fc0f3d8e5ef5fbac0306b1d3c70464a035c396c))
2026-05-25 17:44:38 +00:00
Daisy 17c118cba2 chore(release): 1.2.0 [skip ci]
# [1.2.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.1.0...v1.2.0) (2026-05-25)

### Features

* **nodes:** add VAE options and clone-safe diffusion ([6ff2dc8](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6ff2dc8c24a6f7ddde3182b81bcbe6aad65427f4))
2026-05-25 17:44:38 +00:00
Artificial Sweetener f863fcf645 feat(nodes): add VAE options and clone-safe diffusion
Add VAE encode/decode option nodes across legacy and Comfy v3 exports, with tooltip and registration coverage. Preserve cloned model graph composition for differential diffusion in tiled and regional sampling paths.
2026-05-25 13:40:36 -04:00
Artificial Sweetener 51f4b8464e feat(nodes): add VAE options and clone-safe diffusion
Add VAE encode/decode option nodes across legacy and Comfy v3 exports, with tooltip and registration coverage. Preserve cloned model graph composition for differential diffusion in tiled and regional sampling paths.
2026-05-25 13:40:36 -04:00
Daisy 0679b7600b chore(release): 1.1.0 [skip ci]
# [1.1.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.0.0...v1.1.0) (2026-05-23)

### Bug Fixes

* **detailers:** align SEGS mask blending behavior ([a3120ae](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a3120aebe8834982706d93784a39ab501c6ff40a))
* use SimpleSyrup package identity ([1659d13](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/1659d131f4215fa0baccc4c70024d63590460e67))

### Features

* **detection:** add keep-only SEGS selection ([4f65ae0](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4f65ae0dd36e43347b40ae64039f40e8a67aea48))
2026-05-23 03:45:00 +00:00
Artificial Sweetener 4202ca6d46 fix: use SimpleSyrup package identity 2026-05-22 23:35:35 -04:00
Artificial Sweetener cc2561b557 feat(detection): add keep-only SEGS selection 2026-05-22 22:13:18 -04:00
Artificial Sweetener 57c4b8c5a4 fix(detailers): align SEGS mask blending behavior 2026-05-22 21:42:04 -04:00
Daisy dd514c1aad chore(release): 1.0.0 [skip ci]
# 1.0.0 (2026-05-22)

### Features

* initial release ([4b6525c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4b6525ce6ff42f06a7ffd48a54186fcb625f0e21))
2026-05-22 19:45:24 +00:00
Artificial Sweetener 4b6525ce6f feat: initial release 2026-05-22 15:41:49 -04:00
1771 changed files with 249162 additions and 7215 deletions
+13
View File
@@ -0,0 +1,13 @@
.github/
tests/
tools/
scripts/
web/src/
web/tests/
AGENTS.md
.releaserc.cjs
eslint.config.js
package-lock.json
package.json
tsconfig.json
vitest.config.ts
+62
View File
@@ -0,0 +1,62 @@
name: quality gates
on:
pull_request:
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
env:
SIMPLE_SYRUP_TEST_COMFY_CPU: "1"
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 22.14.0
cache: npm
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install Node dependencies
run: npm ci
- name: Install ComfyUI host dependencies
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/comfyanonymous/ComfyUI.git "$RUNNER_TEMP/ComfyUI"
rsync -a --exclude=".git" "$RUNNER_TEMP/ComfyUI/" "$GITHUB_WORKSPACE/../.."/
pip install -r "$GITHUB_WORKSPACE/../../requirements.txt"
- name: Install Python dependencies
run: pip install -e . pytest pytest-xdist ruff mypy
- name: Check architecture governance
run: python -m tools.check_architecture
- name: Check test governance
run: python -m tools.check_test_governance
- name: Verify Python formatting
run: ruff format --check .
- name: Verify Python lint
run: ruff check .
- name: Verify Python types
run: mypy --strict simple_syrup tests
- name: Verify Python tests
run: pytest -n auto -q -m "not external_artifact"
- name: Verify frontend
run: npm run check:web
+54 -1
View File
@@ -1,6 +1,7 @@
name: release
on:
pull_request:
push:
branches:
- main
@@ -19,7 +20,49 @@ permissions:
contents: write
jobs:
transformers-compatibility:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- label: comfy-v4-floor
version: "==4.50.3"
- label: latest-v4
version: "==4.57.6"
- label: v5-floor
version: "==5.0.0"
- label: latest-v5
version: ">=5,<6"
name: Transformers ${{ matrix.label }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Install compatibility test dependencies
run: >-
python -m pip install pytest torch
"transformers${{ matrix.version }}"
- name: Verify GroundingDINO BERT compatibility
env:
PYTHONPATH: ${{ github.workspace }}/tests
run: >-
python -m pytest -q
--noconftest
--rootdir=tests
--confcutdir=tests
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'
needs: transformers-compatibility
runs-on: ubuntu-latest
env:
SIMPLE_SYRUP_TEST_COMFY_CPU: "1"
@@ -57,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 .
@@ -64,7 +113,7 @@ jobs:
run: mypy --strict simple_syrup tests
- name: Verify Python tests
run: pytest -n auto -q
run: pytest -n auto -q -m "not external_artifact"
- name: Verify frontend
run: npm run check:web
@@ -73,6 +122,10 @@ jobs:
id: release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GIT_AUTHOR_NAME: Daisy
GIT_AUTHOR_EMAIL: daisy@artificialsweetener.ai
GIT_COMMITTER_NAME: Daisy
GIT_COMMITTER_EMAIL: daisy@artificialsweetener.ai
shell: bash
run: |
set -euo pipefail
+29
View File
@@ -0,0 +1,29 @@
repos:
- repo: local
hooks:
- id: architecture-governance
name: Enforce architecture governance
entry: ..\..\venv\Scripts\python.exe -m tools.check_architecture
language: system
pass_filenames: false
always_run: true
- id: test-governance
name: Enforce test governance
entry: ..\..\venv\Scripts\python.exe -m tools.check_test_governance
language: system
pass_filenames: false
always_run: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: mixed-line-ending
args: [--fix=lf]
exclude: '(\.bat$|\.cmd$|\.ps1$)'
- id: trailing-whitespace
- id: check-merge-conflict
- id: check-yaml
- id: check-json
exclude: '(^web/dist/|tsconfig\.json$)'
- id: check-toml
+49 -20
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`
@@ -61,8 +63,8 @@ If a required tool is missing from `..\..\venv`, install or update development d
## Architecture Rules
- Organize code into clear layers with one-way dependencies.
- ComfyUI integration layer: `NODE_CLASS_MAPPINGS`, `NODE_DISPLAY_NAME_MAPPINGS`, node categories, input/output declarations, and ComfyUI import-time registration.
- Node API layer: thin node classes exposing ComfyUI-facing methods such as `INPUT_TYPES`, `RETURN_TYPES`, `FUNCTION`, and execution entry points.
- ComfyUI integration layer: the Comfy v3 `comfy_entrypoint()`, `simple_syrup/nodes_v3/__init__.py::get_nodes()`, v3 schema declarations, node categories, input/output declarations, and ComfyUI import-time registration.
- Node API layer: thin v3 node classes exposing ComfyUI-facing schema and execution entry points.
- Application/service layer: orchestration for node behavior, validation flow, and feature-level use cases.
- Domain layer: stable internal models, value objects, policies, and pure behavior.
- Runtime/adapter layer: filesystem access, image/audio/model IO, ComfyUI object adaptation, subprocess boundaries, network boundaries, and optional external integrations.
@@ -83,7 +85,7 @@ If a required tool is missing from `..\..\venv`, install or update development d
- For behavior-critical areas, work in two steps:
1. Add characterization/regression tests for existing behavior.
2. Perform structural changes behind those tests.
- Behavior-critical areas include node registration, `INPUT_TYPES`, `RETURN_TYPES`, widget names, output ordering, execution return shapes, workflow compatibility, validation behavior, file IO, model IO, image/audio tensor handling, and ComfyUI import behavior.
- Behavior-critical areas include node registration, v3 schema declarations, return metadata, widget names, output ordering, execution return shapes, workflow compatibility, validation behavior, file IO, model IO, image/audio tensor handling, and ComfyUI import behavior.
- Do not start structural changes in an area without behavior safeguards for that area.
- When behavior spans multiple components, trace the current ownership and data flow before editing.
- Correct the ownership model instead of layering compensating patches across consumers.
@@ -94,12 +96,40 @@ 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.
- Do not rename node classes, display names, categories, input keys, output names, return types, or function names without explicit approval.
- Keep ComfyUI-facing node classes small and predictable.
- `INPUT_TYPES` must be deterministic and must not perform expensive IO.
- V3 schema declarations must be deterministic and must not perform expensive IO.
- Importing the node pack must not perform heavy computation, network access, model loading, or destructive filesystem operations.
- Node execution must validate inputs before performing side effects.
- Node execution must return exactly the declared output shape.
@@ -121,25 +151,23 @@ If a required tool is missing from `..\..\venv`, install or update development d
- Avoid implementation jargon unless the user needs it to make a good workflow decision.
- Do not repeat the field name as a definition.
- Do not document removed behavior, imagined alternatives, or choices the product does not expose.
- Keep legacy `INPUT_TYPES` tooltips and Comfy v3 schema tooltips aligned when both export paths expose the same node or field.
- Keep Comfy v3 schema tooltips aligned with the current node behavior.
## ComfyUI Node Export Rules
- When adding, renaming, or removing a ComfyUI node, update and verify every export path used by this repository.
- Legacy ComfyUI mapping exports must be updated in `simple_syrup/nodes/__init__.py`:
- `NODE_CLASS_MAPPINGS`
- `NODE_DISPLAY_NAME_MAPPINGS`
- `__all__`
- Comfy v3 entrypoint exports must be updated when the node should be visible through the v3 API:
- Comfy v3 is the only supported ComfyUI node export path.
- Do not add, preserve, or restore legacy ComfyUI mapping exports.
- The root package export in repository root `__init__.py` must expose `comfy_entrypoint` and must not expose `NODE_CLASS_MAPPINGS` or `NODE_DISPLAY_NAME_MAPPINGS`.
- `simple_syrup/nodes_v3/__init__.py::get_nodes()` is the authoritative node registry.
- When adding, renaming, or removing a ComfyUI node, update and verify:
- the v3 wrapper class when needed
- `simple_syrup/nodes_v3/__init__.py`
- `get_nodes()`
- a v3 wrapper class when needed
- The root package export in repository root `__init__.py` must continue exposing the relevant mappings and `comfy_entrypoint`.
- Tests must cover every export path used by the node:
- A registration test must assert the node id and display name exist in `NODE_CLASS_MAPPINGS` and `NODE_DISPLAY_NAME_MAPPINGS`.
- A v3 entrypoint test must assert `comfy_entrypoint().get_node_list()` includes the node when it is expected to be visible through Comfy v3.
- If a v3 node is conditional, tests must cover both the available and unavailable conditions and prove unrelated v3 nodes remain exported.
- Do not consider a node addition complete from `NODE_CLASS_MAPPINGS` alone. A node is not fully exported until every repository-supported ComfyUI export path is updated and tested.
- tests that exercise the v3 schema, entrypoint export, and execution behavior
- Maintained nodes must keep stable `SimpleSyrup.*` node ids unless the maintainer explicitly approves a workflow-facing rename.
- If a v3 node is conditional, tests must cover both the available and unavailable conditions and prove unrelated v3 nodes remain exported.
- Do not maintain a compatibility layer, fallback registry, or migration path for old ComfyUI versions that only load `NODE_CLASS_MAPPINGS`.
- A node is not fully exported until the v3 entrypoint returns it and tests prove the intended schema and behavior.
## Code Organization and Readability
@@ -215,7 +243,7 @@ If a required tool is missing from `..\..\venv`, install or update development d
- Use real behavior tests over excessive mocking.
- Mock only external boundaries such as ComfyUI runtime calls, filesystem errors, network calls, subprocesses, random generation, and time.
- Node behavior must be tested at the narrowest useful level and through integration-style tests when ComfyUI-facing shape matters.
- Node registration changes require tests for exported mappings.
- Node registration changes require tests for the v3 entrypoint and v3 schemas.
- Tooltip coverage must be tested for exported node descriptions, inputs, and outputs supported by each ComfyUI API path.
- Input/output signature changes require workflow-facing compatibility tests.
- Runtime behavior requires tests for success and failure paths.
@@ -262,6 +290,7 @@ npm run build:web
## ComfyUI Frontend Rules
- ComfyUI frontend extensions must be registered from TypeScript source under `web/src`.
- If an element belongs to a component, integrate it into that component's structure, layout, input handling, and lifecycle. Do not fake ownership by positioning an unrelated element over the component or synchronizing it through external coordinates. Use detached overlays only for UI that is semantically an overlay, such as menus, tooltips, dialogs, and drag ghosts.
- Settings-panel behavior must use Comfy's frontend settings API.
- Browser settings are not authoritative for backend behavior.
- Any frontend setting that affects backend node declarations or execution must be mirrored through an explicit backend route or persisted backend settings file.
@@ -301,7 +330,7 @@ Per change, all of the following are required:
- Frontend source is typed and tested when touched.
- Generated frontend artifacts are rebuilt from source.
- `npm run lint:web`, `npm run typecheck:web`, `npm run test:web`, and `npm run build:web` pass when frontend code exists or is touched.
- New, renamed, or removed nodes are updated in all legacy and Comfy v3 export paths, with tests proving both paths expose the intended node set.
- New, renamed, or removed nodes are updated in the Comfy v3 export path, with tests proving the v3 entrypoint exposes the intended node set.
## Commit Policy
+139 -8
View File
@@ -1,23 +1,154 @@
# [1.1.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.0.0...v1.1.0) (2026-05-23)
# [1.13.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.12.0...v1.13.0) (2026-10-02)
### Bug Fixes
* **detailers:** align SEGS mask blending behavior ([74c83a6](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/74c83a6a70407aea6a34b0c0e31408f32b929882))
* use SimpleSyrup package identity ([4fa5582](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4fa5582796e9ace2a8805cb1ea2aebce65551e89))
* **negpip:** support Krea attention on ComfyUI 0.28 ([e4eabfe](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/e4eabfefd540f3a6066c29761cefb8f8b3c80f68))
### Features
* **detection:** add keep-only SEGS selection ([8f8ee91](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/8f8ee91dea3ce1a044c0e61b482e571c51b372bc))
* **sampling:** add noise inversion and composable sampler options ([be4bd9b](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/be4bd9bb162f6ed4252e0c4d4ef0f3efebe8c674))
* **sampling:** refine sampler options and inversion controls ([ab7cebc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/ab7cebcebc4d5dfa95aa8d834456af1778fe56a6))
# 1.0.0 (2026-05-22)
# [1.12.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.1...v1.12.0) (2026-09-29)
### Bug Fixes
* **release:** satisfy RES4LYF publication contracts ([04dc35a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/04dc35af00163689509cace1541a8e7d33b5053e))
### Features
* initial release ([e513baf](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/e513baf70a20306856e40fbf2afd80b25f5655a6))
* **sampling:** add RES4LYF sampler methods and schedules ([7b1efef](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/7b1efef47e20b9bf6f032dd57971899d99881822))
# Changelog
## [1.11.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.11.0...v1.11.1) (2026-09-25)
All notable changes to this project will be documented in this file.
### Bug Fixes
* **release:** attribute automation to Daisy ([2ae545d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/2ae545d64d70a454f635ee647fb7e6a1c3500b9b))
# [1.11.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.1...v1.11.0) (2026-09-25)
### Features
* **sampling:** make negative conditioning optional ([0bc81dc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0bc81dc4d00a13d42c65440da2058e94a505e8d5))
## [1.10.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.10.0...v1.10.1) (2026-09-24)
### Bug Fixes
* **ci:** expose test support to compatibility jobs ([0517f71](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0517f71da891414e9773c5cd47879ff748492e70))
* **governance:** enforce SugarSubstitute quality standards ([bbaed2c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/bbaed2c90656cd4e65e5bfcd4c0451f90ec2d7c7))
# [1.10.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.3...v1.10.0) (2026-09-21)
### Features
* **loaders:** add Krea 2 model loader ([166f029](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/166f029d12e5fce019133cc38be7279d05a5ecbc))
## [1.9.3](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.2...v1.9.3) (2026-09-20)
### Bug Fixes
* **attention-coupling:** restore regional LoRA sampling ([0255a0f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0255a0f5044278f14452b6b2582ec6646083f756))
## [1.9.2](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.1...v1.9.2) (2026-09-20)
### Bug Fixes
* **registry:** remove flagged package content ([f3a53b5](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f3a53b5ec6c080d98f7e9599cf55e849cf338021))
## [1.9.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.9.0...v1.9.1) (2026-09-20)
### Bug Fixes
* **contextual-diffusion:** project reference latents into views ([4cd780a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4cd780a2451aa472ce826834e4426b65693c46e8))
# [1.9.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.8.0...v1.9.0) (2026-09-19)
### Features
* **prompts:** add automatic NegPiP support ([6d052e9](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6d052e9800985696972bf431fa7dae4972a56313))
# [1.8.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.7.1...v1.8.0) (2026-09-19)
### Bug Fixes
* **downloads:** keep unknown sizes indeterminate ([a31467c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a31467cd3a4299e9e3929d44281018dba0322b3e))
* **models:** hide installed catalog choices ([d887e87](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d887e87e03eb6d0d9d5325fe43b67fbd9e47cf71))
### Features
* **models:** add curated ultralytics downloads ([b907fa2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b907fa2a17afa30170bc22cf1134a750241e55c2))
* **models:** prioritize installed ultralytics choices ([d30e04f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d30e04f229366d3e1d2388bd4d713b2b47a12a1f))
## [1.7.1](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.7.0...v1.7.1) (2026-09-11)
### Bug Fixes
* **regional:** preserve shared model patch ancestry ([6059a3f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/6059a3f913a9502671666e83faeb8686a7a8da27))
# [1.7.0](https://github.com/Artificial-Sweetener/SimpleSyrup/compare/v1.6.0...v1.7.0) (2026-09-05)
### Bug Fixes
* **anima:** support regional prompting across Comfy versions ([41a234a](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/41a234a85a4cfcdc4cfce68b80b4b9982719aab4))
* **attention:** preserve anchored concept geometry ([1136efd](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/1136efd2ad8708b14320d04eab2f6489efbec282))
* **cache:** make integer narrowing checker-independent ([2547767](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/254776791766c41c75a69ceb5b207c54949446c9))
* **detailers:** align SEGS mask blending behavior ([a3120ae](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a3120aebe8834982706d93784a39ab501c6ff40a))
* **groundingdino:** support transformers v4 and v5 ([3387c03](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/3387c03eb59f244d56f6a0dfe86cedab1847a8e2))
* **mask:** preserve missing-alpha image geometry ([caf7d37](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/caf7d37a154ddc62405807b56550efdaa831d09e))
* **media:** stabilize native ordered preview controls ([1235652](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/123565225a1104234a7c9f5c43af0772c7508db8))
* **regional:** align prompt batches and LoRA hooks ([656d197](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/656d1970e12be18304b057b07204dcbbe367432b))
* **runtime:** centralize Comfy patcher lifecycle ([0e5f513](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/0e5f513ae0f33f40c6a8bd09161043a5af598392))
* **sampling:** normalize model-specific latent layouts ([b2084a7](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b2084a7a9bf04709af51380bc1ef4a09ccb9babc))
* **tiled-diffusion:** clamp overlap for small latents ([fecba36](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fecba36e4e11f0da681c6a5d9d42e18093d741fc))
* **tools:** return host-native checkpoint selections ([c7cb8d2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/c7cb8d29f83ebd44b48038b7ce5e65a0a6f445b4))
* use SimpleSyrup package identity ([1659d13](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/1659d131f4215fa0baccc4c70024d63590460e67))
### Features
* **anima:** add cached quantization profiles ([c20664f](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/c20664f8925305465ccb4c028d0a78a6364a037d))
* **attention:** add sampler-derived concept regions ([8ca5d2c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/8ca5d2c325e1caee822883ba568b25f721e51343))
* **attention:** default regional prompts to full weight ([f829321](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f82932193951bae75d59e1c7c7dba2d187e3c175))
* **attention:** improve concept isolation fidelity and speed ([01826ad](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/01826ad1b7c64b11c8af031691403ecf521cb1b5))
* **attention:** refine attention-derived region masks ([dff84cc](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/dff84cce2322e64b8a9ada91b84550faf3a5c7a1))
* **conditioning:** add regional prompting and SEP-local LoRAs ([d3de028](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/d3de02816b24bea129e77b82568ad47a0fd0ba99))
* **conditioning:** support labeled prompt separators ([a708e0b](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a708e0b4187b0d5f9aeb58f5ab0d276b8a046395))
* **detailing:** add external llm segs tagging ([207c449](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/207c4492908371f41aca84c74332c1a1f63d8045))
* **detection:** add keep-only SEGS selection ([4f65ae0](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4f65ae0dd36e43347b40ae64039f40e8a67aea48))
* initial release ([4b6525c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/4b6525ce6ff42f06a7ffd48a54186fcb625f0e21))
* **loaders:** add automatic FLUX model loaders ([33bff75](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/33bff75af1b001b716a45f4fadc9e0e0aa20ced1))
* **masking:** expand segmentation tooling and progress ([adba198](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/adba1981a4bdb43307496a84cccbfe100ae2174f))
* **media:** add native ordered loaders and SEGS preview ([fcf38f2](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fcf38f2010cfadc76be74410857387c74db3b575))
* **nodes:** add VAE options and clone-safe diffusion ([5fc0f3d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/5fc0f3d8e5ef5fbac0306b1d3c70464a035c396c))
* **prompt-control:** add schedule and encode prompt node ([af32cec](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/af32cec99f5bdadcbed9f8c33db0d816ad4b72f0))
* **regional:** add native SDXL adapter execution ([068e3db](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/068e3db17d13c875a383c85e6cf931f96459c3b1))
* **regional:** add universal attention coupling foundation ([edfc26c](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/edfc26c9122ff0c18d63e9b80832d587594e9e62))
* **regional:** build universal adapter execution foundation ([864852d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/864852dc9e591a3041e23b342346495a7fbf6589))
* **regional:** complete capability-routed execution ([fe96a20](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/fe96a206dbf1edcae022b1346f998ed184142062))
* **regional:** complete persistent regional LoRA execution ([7b5987d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/7b5987d6fa66f8deee2655d18b1209bbe20271d6))
* **sampling:** add contextual diffusion sampler ([24bf630](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/24bf6309023c552f65947814d9641555a73c8337))
* **sampling:** add deterministic seed variation ([7921be3](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/7921be3f9067a26fe5fa47fb8fb370e7cb1679f3))
* **sampling:** add regional diffusion sampling ([f7dffca](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/f7dffcacfe104be173793ce412f994519e11e02e))
* **sampling:** bypass inactive attention coupling ([b2b8dc4](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/b2b8dc4b016307fd351892f50264c64532898681))
* **sampling:** expose evaluated context SEGS ([04a2c3e](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/04a2c3e6e90f9bbb9e3922412844afe5a4e6869f))
* **segmentation:** add interactive SEGS preview ([c9e303e](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/c9e303ec4727576af124d9e8ea20d0103f67e7ae))
* **segmentation:** add SAM region overlay ([a72c796](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/a72c796d8d6d64a52fed7d281fe53eebc74639ed))
* **segmentation:** add SAM-guided tiled diffusion ([968090d](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/968090de87a4fad726fd37d0a08966c01f11a8fd))
* **segs:** add regional batching and wd14 tagging nodes ([600d9e3](https://github.com/Artificial-Sweetener/SimpleSyrup/commit/600d9e311b5b8c45762e15c88f54df33454261b5))
+144 -105
View File
@@ -1,174 +1,205 @@
# SimpleSyrup
**SimpleSyrup** is a ComfyUI node pack that grew out of moving my A1111/WebUI image workflows into ComfyUI graphs.
[![Comfy Registry](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.comfy.org%2Fnodes%2FSimpleSyrup&query=%24.latest_version.version&label=Comfy%20Registry&color=5b5bd6)](https://registry.comfy.org/publishers/artificialsweetener/nodes/SimpleSyrup) [![Comfy Registry downloads](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.comfy.org%2Fnodes%2FSimpleSyrup&query=%24.downloads&label=downloads&color=5b5bd6)](https://registry.comfy.org/publishers/artificialsweetener/nodes/SimpleSyrup) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) [![License: AGPL-3.0-or-later](https://img.shields.io/badge/License-AGPL--3.0--or--later-blue.svg)](LICENSE)
The WebUI side shows up in the things I kept reaching for: ADetailer-style inline `[SEP]` prompt batches, tiled diffusion, familiar checkpoint loader controls, CLIP skip where WebUI users expect it, and sampler/scheduler extras. The Comfy side matters just as much: the detailers are modeled heavily on ComfyUI Impact Pack's SEGS workflow, and the utility nodes are built for graph readability, ordered data, and explicit runtime behavior.
**SimpleSyrup** is a ComfyUI node pack that grew out of moving my A1111/WebUI image workflows into ComfyUI.
SimpleSyrup pulls from a few different places:
The WebUI influence shows up all over the pack. I missed ADetailer's inline prompt batches, tiled diffusion, CLIP skip beside my checkpoint, and some of the sampler behavior I was used to. I also wanted the regional pieces to work with Impact Pack SEGS so I could use the same regions across detectors and detailers.
- Moving from **A1111/WebUI** to **ComfyUI** is the reason this pack brings tiled diffusion, familiar checkpoint loader control grouping, ADetailer-style prompt splitting, and A1111-flavored sampler behavior into node graphs.
- **ComfyUI Impact Pack** is the main influence for the SEGS and detailer shape: detectors create SEGS, detailers sample cropped areas, masks are feathered, and results are composited back into the source image.
- **ADetailer** is the influence for inline `[SEP]` per-segment prompt batches.
- The remaining utility pieces cover practical graph needs: GPU Lanczos resizing through TorchLanc, latent provenance helpers, and smaller nodes for image, prompt, and conditioning workflows.
The pack now covers model loading, regional prompting and segmentation, high-resolution sampling, image and mask utilities, tagging, and the smaller pieces I need to keep those workflows readable.
[**SugarSubstitute**](https://github.com/Artificial-Sweetener/SugarSubstitute), my native desktop front-end for ComfyUI, can use Cubes built from any ComfyUI nodes available in its connected environment. Its first-party [**Base-Cubes**](https://github.com/Artificial-Sweetener/Base-Cubes) pack uses SimpleSyrup for model loading, regional prompts, segmentation, high-resolution sampling, and other graph work. You can also install SimpleSyrup on its own and use the nodes in normal ComfyUI workflows.
## Highlights
- Impact-compatible SEGS detection, sorting, combining, tiling, and detailing.
- Detailer nodes modeled heavily on ComfyUI Impact Pack's SEGS workflow.
- ADetailer-style inline `[SEP]` per-segment prompt batches.
- MultiDiffusion, Mixture of Diffusers, and regional MultiDiffusion sampling paths.
- A WebUI-familiar checkpoint loader with CLIP skip and VAE override controls.
- A Simple Anima loader that keeps Anima's model, VAE, dtype, and device controls together.
- Loaders for SAM, GroundingDINO, ViTMatte, Ultralytics, and WD14.
- Tile & Tag SEGS workflows that run WD14 on deterministic tile crops and keep conditioning aligned to tile order.
- KSampler extras including A1111-style Euler ancestral behavior, AYS, GITS, automatic A1111 scheduling, and beta57.
- GPU Lanczos resizing through [TorchLanc](https://github.com/Artificial-Sweetener/TorchLanc), with batch and mask handling.
- Provenance-aware latent helpers for recovering the latent behind an unmodified decoded image.
- Settings-backed model dropdowns that can show known downloadable models or only locally installed ones.
- 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.
- WD14 and external vision LLM tagging that stays aligned with the right regions.
- Ordered image and mask loading, GPU Lanczos resizing, tiled VAE options, and provenance-aware latent tools.
- WebUI-inspired sampling extras including seed variation, A1111 Euler ancestral behavior, AYS, GITS, `automatic_a1111`, and RES4LYF sampler methods and schedules.
## Installation
## Contents
**Recommended: install through ComfyUI Manager**
- [Install](#install)
- [Model loading](#model-loading)
- [Large images and high-resolution edits](#large-images-and-high-resolution-edits)
- [Contextual Diffusion](#contextual-diffusion)
- [Tiled Diffusion](#tiled-diffusion)
- [SEGS, detailers, and regional prompts](#segs-detailers-and-regional-prompts)
- [Tagging images and regions](#tagging-images-and-regions)
- [Images, masks, latents, and sampler extras](#images-masks-latents-and-sampler-extras)
- [Settings and optional integrations](#settings-and-optional-integrations)
- [License, acknowledgements, and research](#license-acknowledgements-and-research)
Open **Manager** from the ComfyUI toolbar, click **Custom Nodes Manager**, search for **SimpleSyrup**, and click **Install**. Restart ComfyUI after installation.
## Install
**Manual install**
### ComfyUI Manager
If you would rather install it yourself, clone this repo into `ComfyUI/custom_nodes/`, activate your **ComfyUI venv**, and install this node pack's requirements.
Open Manager and search the **Node Pack** list for **SimpleSyrup**, then select it and click **Install**. Restart ComfyUI when it finishes.
ComfyUI still has two Manager interfaces in circulation. In the legacy interface, the search is under **Custom Nodes Manager**.
### Manual install
Clone the repository into `ComfyUI/custom_nodes/` and install the requirements with the same Python environment that runs ComfyUI.
For a normal Windows virtual environment:
```powershell
cd ComfyUI\custom_nodes
Set-Location ComfyUI\custom_nodes
git clone https://github.com/Artificial-Sweetener/SimpleSyrup.git
cd SimpleSyrup
pip install -r requirements.txt
Set-Location SimpleSyrup
..\..\venv\Scripts\python.exe -m pip install -r requirements.txt
```
ComfyUI already provides the heavy shared runtime stack, including PyTorch. SimpleSyrup adds the packages it needs for specific features, including TorchLanc, Ultralytics, ONNX Runtime, Segment Anything, and Hugging Face download helpers.
For ComfyUI Windows Portable, run this from the portable installation folder after cloning the repository:
## The Nodes
```powershell
.\python_embeded\python.exe -m pip install -r .\ComfyUI\custom_nodes\SimpleSyrup\requirements.txt
```
SimpleSyrup is organized around workflow jobs, not socket types.
Restart ComfyUI after installation. SimpleSyrup uses ComfyUI's v3 extension API, so you need a current version of ComfyUI for the nodes to appear.
### Impact-Style SEGS Workflows
ComfyUI already supplies PyTorch and the rest of the shared runtime. SimpleSyrup installs the packages used by its own features, including [TorchLanc](https://github.com/Artificial-Sweetener/TorchLanc), Ultralytics, ONNX Runtime, Segment Anything, and the Hugging Face download helpers.
SimpleSyrup speaks the Impact Pack SEGS shape on purpose. It can read Impact-style SEGS, sort them, combine them, tile them, and emit SEGS payloads that Impact-style consumers can read.
## Model loading
The detailer nodes are modeled heavily on **ComfyUI Impact Pack**. Detectors create SEGS, SEGS choose the crop areas, crops are sampled, masks are feathered, and the results are composited back into the source image.
Loading a checkpoint used to feel like choosing one file. Newer model families can mean a diffusion model, several text encoders, a VAE, and then the precision and device choices for all of them. I made the SimpleSyrup loaders so I could deal with that setup once and get on with the workflow.
- **Prompt SEGS w/ SAM** uses GroundingDINO to find prompt-matched boxes, SAM to segment them, optional negative prompting to subtract unwanted areas, and optional ViTMatte refinement to clean up mask edges. It returns both SEGS and a combined mask.
- **Detect SEGS w/ Ultralytics** runs bbox or segmentation detection, filters by threshold and size, supports label filtering, and returns Impact-compatible SEGS plus a combined mask.
- **Detail SEGS by Scale Factor** upscales each SEG crop, samples it, downsizes it back, and composites it into the original image with feathering and optional denoise masks.
- **Detail SEGS by Scale Factor w/ Tiled Diffusion** uses the same crop/detail idea, but samples large crops through SimpleSyrup's tiled diffusion path.
- **Detail SEGS as Regions** runs one regional MultiDiffusion pass over the image and pairs every SEG with its matching `CONDITIONING_BATCH` entry.
**Simple Load Checkpoint** is the normal checkpoint loader. It has an optional VAE override and keeps CLIP skip beside the model controls where I expect to find it.
The regional node is different from the per-crop detailers. It samples one full-image latent with regional conditioning, using the global prompt for full-image context while each SEG gets its own positive conditioning.
**Simple Load Anima** loads Anima with its Qwen text encoder and Qwen image VAE. You can select every part yourself. If you don't want to, the automatic choices can find and download the known checksum-pinned support files.
### Per-Segment Prompt Batches
**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.
SimpleSyrup layers the ADetailer habit I missed from WebUI on top of the Impact-style detailer shape: writing per-segment prompt batches inline with `[SEP]`.
**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.
Those prompts become an ordered `CONDITIONING_BATCH`, so prompt 1 stays matched to SEG 1, prompt 2 stays matched to SEG 2, and so on. This keeps the graph readable when each detected item needs its own prompt.
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.
- **Encode Prompt Batch** splits prompt text with `[SEP]` and encodes ordered positive and negative `CONDITIONING_BATCH` values.
- **Conditioning Batch Start** and **Conditioning Batch Append** build ordered conditioning batches for per-segment and regional workflows.
## Large images and high-resolution edits
### Tile, Tag, and Guide
Tiled diffusion handles the obvious large-image problem: sometimes the latent is too big to evaluate all at once. There is a worse version. The model has enough memory to run, but the canvas is so far outside its normal working resolution that it starts making terrible decisions anyway.
**Tile & Tag SEGS** is for workflows where tile regions should carry their own generated prompt guidance.
Tiling keeps each evaluation small. It does not make the tiles understand the same complete image. That second problem is why I made Contextual Diffusion.
It splits an image into deterministic tile SEGS, crops each tile, runs WD14 tagging on each crop, prefixes your universal positive prompt text, and CLIP-encodes the resulting prompts into a `CONDITIONING_BATCH`. The order matters: the conditioning batch is aligned to the tile SEGS order so downstream per-SEG or regional nodes can pick the right prompt for the right area.
### Contextual Diffusion
That is the kind of thing that is easy to do once by hand and annoying to keep correct in a real graph.
**KSampler (Contextual Diffusion)** is an original sampling method I developed for editing and refining oversized latent canvases.
### Tiled Sampling
The first real target was a 2160 × 3072 source image I wanted to edit with FLUX.2 Klein 4B. Downscaling made the edit coherent, but that defeated the point of starting with a high-resolution source. Ordinary tiled diffusion kept much more detail and looked promising at first. Then I looked at the whole image. One tile had found a figure, another had invented a second figure, and different parts of the cathedral had become different buildings. The overlaps were smooth! The scene was still nonsense.
**KSampler (Tiled Diffusion)** is a KSampler-style node with selectable **MultiDiffusion** and **Mixture of Diffusers** modes.
I needed Klein to see the complete composition and the full-resolution detail during the same denoising process. Contextual Diffusion does that by making overlapping local predictions on the original latent and a second prediction from a smaller, aspect-preserving view of the whole image during the early steps.
It splits the latent into tiles, denoises tile predictions, and blends them back together during sampling. MultiDiffusion averages overlapping predictions. Mixture of Diffusers uses weighted blending. Both are there because large images and large upscale passes often need a different strategy than normal full-latent denoising.
That took some trial and error. Directly blending the whole-image prediction into the tiles made the result blurry. Leaving it active too long produced smears, repeated edges, and other low-resolution garbage in the final detail. What finally worked was subtracting the low-frequency interpretation already present in the tiled prediction and adding only the difference from the whole-image prediction:
Tiled diffusion is here because it was one of the high-resolution workflow tools I kept reaching for in my WebUI setup. SimpleSyrup brings MultiDiffusion and Mixture of Diffusers behavior into normal Comfy sampling nodes, so large latent jobs can be tiled without giving up Comfy's explicit conditioning and graph wiring.
`prediction = local + scheduled_weight × (global_upsampled − local_low_frequency)`
This also matters for Anima workflows. Anima can produce beautiful images, but pushing beyond its comfortable native size with untiled diffusion upscale can smear detail instead of improving it. The tiled path gives those workflows another route.
The whole-image correction is strongest at the beginning and can decay before the model starts settling fine texture. Distilled Klein models commonly finish in four steps, so even one corrected step is already a quarter of the denoising process.
### Diffusion Loaders
Contextual Diffusion is for edits the model already knows how to make at a normal resolution. I use it for clothing, material, color, jewelry, expression, local lighting, and other changes where I want to keep the source pose and composition. If I need a completely new pose, camera, and environment, I establish those at a normal working resolution first and refine the result afterward.
**Simple Load Checkpoint** is meant to feel familiar if you come from WebUI, where the common generation controls live near the model selection.
FLUX.2 reference latents stay complete and ordered in every local and whole-image evaluation. This lets one image retain the target composition while other images continue to provide complete subject or style references. The sampler also supports Anima's singleton-depth latent shape, which is useful when refining an illustration after a conventional resize.
- **Simple Load Checkpoint** loads a checkpoint, optionally replaces the checkpoint VAE, and keeps CLIP skip in the same place.
- **Simple Load Anima** is for Anima workflows. It loads Anima with the Qwen text encoder and Qwen image VAE it expects. You can choose the files yourself or let SimpleSyrup resolve the known Anima assets automatically. It keeps model, VAE, dtype, and device decisions together so the rest of the graph can get on with the image.
You can connect SEGS to replace the normal grid with a region-guided context plan. This gives you some control over where the local windows fall. Earlier versions ran regular tiles and a second bank of SAM views at the same time because I thought more views of the important objects would help. Instead, I got duplicated hats, extra limbs, repeated garment edges, and other semantic echoes. It was the wrong architecture, so I removed it. The current method uses one local plan at a time and returns its actual windows through `contexts_segs` so you can see what it evaluated.
### Model and Detector Loaders
The cost is one tiled prediction pass per denoising step and one smaller whole-image evaluation for each step using the correction. UniPC, regional conditioning, ControlNet, and GLIGEN are currently rejected because I haven't validated their spatial behavior across both context sizes.
These nodes load the models used by detection, segmentation, tagging, matting, and compatibility workflows.
I found the formula by comparing the failures and adjusting the method until the whole-image branch could fix composition without taking the detail away from the tiles. After I had implemented it, I learned about [Upsample Guidance](https://arxiv.org/abs/2404.01709). It uses a closely related separation between low-frequency guidance and a high-resolution residual.
- **SAM Model Loader** loads SAM, SAM-HQ, and MobileSAM choices for segmentation workflows.
- **GroundingDINO Model Loader** loads GroundingDINO with an explicit BERT text encoder.
- **ViTMatte Model Loader** loads ViTMatte for mask edge refinement.
- **Load Ultralytics Model** loads an Ultralytics detector and exposes both SimpleSyrup's native detector model and Impact-style compatibility outputs.
- **Load WD14 Tagger** loads a SmilingWolf WD14 ONNX model and its tag CSV.
- **LayerStyle SAM Models Adapter** splits a LayerStyle `LS_SAM_MODELS` bundle into separate `SAM_MODEL` and `DINO_MODEL` outputs.
- **Grounded SAM Model Info** returns JSON metadata for selected SAM and GroundingDINO models.
Upsample Guidance wasn't part of how I developed Contextual Diffusion. There are also practical differences: my high-resolution prediction is assembled from bounded tiles, the complete image is fit into an aspect-preserving context, reference latents stay whole, and the node has its own early-step controls and optional SEGS planning. Still, the mathematical relationship is real. My experiments are qualitative, and I describe the method as independent development of a related multiscale idea instead of claiming priority over that paper.
The LayerStyle adapter exists because good ComfyUI workflows should not make you reload the same SAM or GroundingDINO model just because one node pack uses a different socket shape.
### Tiled Diffusion
### Sampler and Scheduler Extras
**KSampler (Tiled Diffusion)** is the more direct tiled sampler. It divides the latent into overlapping contexts, evaluates them in batches, and combines the predictions during every denoising step.
**KSampler (Extras)** keeps the normal Comfy sampler shape, but adds sampler and scheduler behavior I wanted available without dragging in a separate sampler stack.
MultiDiffusion averages the overlapping predictions. Mixture of Diffusers uses Gaussian weights that favor the center of each context. This works well when local evaluation and overlap blending are enough for the image. Contextual Diffusion adds the whole-image correction for edits where the separate contexts lose track of the complete scene.
It includes:
The same tiled sampling path is available in **Detail SEGS by Scale Factor w/ Tiled Diffusion** for large detailer crops and **KSampler (Prompt by Tiled Region)** for regional prompts on large canvases.
- `euler_a_a1111`, an A1111/k-diffusion-style Euler ancestral sampler.
- **AYS SD1** and **AYS SDXL** schedules.
- **GITS**.
- **automatic_a1111** scheduler behavior.
- **beta57**, a local reimplementation of the RES4LYF beta57 scheduler preset.
## SEGS, detailers, and regional prompts
The node still uses Comfy-style seed handling, partial denoise behavior, progress callbacks, and normal positive/negative conditioning inputs.
Impact Pack already had a useful way to represent detected and masked regions: `SEGS`. I built SimpleSyrup around the same shape so regions can move between compatible detectors, these nodes, and Impact workflows without reloading models or rebuilding the masks.
### Image, Prompt, and Latent Utilities
**Prompt SEGS w/ SAM** uses GroundingDINO to find objects from text and SAM to segment them. It also supports negative prompting and optional ViTMatte edge refinement. **Detect SEGS w/ Ultralytics** creates regions from bounding-box or segmentation models with confidence, label, and size filtering. Existing masks can enter the same workflow through **Mask to SEGS**, while **SEGS from SAM Output** runs automatic unprompted segmentation from a connected SAM model.
These nodes handle the smaller jobs that show up all over image workflows.
**Simple Preview SEGS** shows the regions over the image, lets you select them from an interactive grid, and passes the original SEGS onward. **Batch SEGS** combines several ordered SEGS inputs.
- **Resize Image to Target** resizes image batches with stretch, keep-aspect, crop, and pad modes. It can round output dimensions to a divisibility target, anchor crop or pad placement, process batches in chunks, resize a mask with the image, and use GPU Lanczos through [TorchLanc](https://github.com/Artificial-Sweetener/TorchLanc).
- **Simple VAE Encode** encodes an image to latent space, but reuses the source latent when the graph proves the image came from an unmodified `VAEDecode`.
- **Upscale Latent From Image** finds the latent behind an unmodified decoded image and expands to Comfy's latent upscale behavior.
- **Latent Diagnostics** passes a latent through unchanged while reporting shape, dtype, device, and tiling-fit details.
- **Prompt Encode Style** creates Prompt Control style tags from an encode-style selection.
- **Prompt Encode Style & Normalization** creates Prompt Control style and normalization tags together.
- **Scale Factor** provides a bounded scale multiplier for nodes that expect one.
- **Seed** provides a reusable seed value with ComfyUI seed controls.
The scale-factor detailers work on one crop at a time. They enlarge the crop, sample it, shrink it back, and composite it into the source image with feathering and optional denoise masks. **Detail SEGS as Regions** takes another route: it keeps the full image in one MultiDiffusion pass, uses the global conditioning across the image, and pairs each SEG with its own ordered regional conditioning.
The provenance nodes trace the graph. They do not guess from tensor values. If an image has been loaded, edited, cropped, detailed, resized, or otherwise changed, the original latent provenance is broken and the node will not pretend otherwise.
The prompt batching came directly from ADetailer. **Encode Prompt Batch** splits positive and negative text with `[SEP]`. You can write `[SEP|name]` to keep a long prompt readable; matching still follows the order of the prompts and regions. The first prompt is global, and each later prompt belongs to the corresponding mask or SEG.
## Settings
**Conditioning Batch Start**, **Conditioning Batch Append**, and **Batch Region Conditioning** build the same ordered structure from existing conditioning. **Compose Regional Conditioning** converts a global-first prompt batch and ordered masks into normal masked Comfy conditioning. The dedicated **KSampler (Prompt by Region)** and tiled version apply the regional prompt batch during sampling.
SimpleSyrup adds one ComfyUI setting:
If [ComfyUI Prompt Control](https://github.com/asagi4/comfyui-prompt-control) is installed, SimpleSyrup also exports **Encode Prompt Batch w/ Prompt Control** and **Schedule & Encode Prompts**. They preserve Prompt Control scheduling and LoRA hooks across `[SEP]` regions. The rest of the pack loads normally when Prompt Control is absent.
- **SimpleSyrup: Show downloadable models in loader dropdowns**
Model loading stays separate from detection. There are loaders for SAM, GroundingDINO, ViTMatte, and Ultralytics. **LayerStyle SAM Models Adapter** accepts a ComfyUI Layer Style Advance `LS_SAM_MODELS` bundle and exposes the loaded SAM and GroundingDINO models through the normal sockets used here.
When this is enabled, supported loaders show known downloadable model choices even if the files are not installed yet. When it is disabled, those dropdowns only show models SimpleSyrup can verify locally.
## Tagging images and regions
This setting affects SAM, GroundingDINO, ViTMatte, and WD14 loader dropdowns. Anima's automatic Qwen text encoder and VAE resolution is handled by the Anima loader itself.
**Load WD14 Tagger** loads a SmilingWolf WD14 ONNX model and its tag CSV. **Tag SEGS w/ WD14** runs the tagger on existing SEG crops and keeps the resulting conditioning in the same order. **Tile & Tag SEGS** makes a deterministic set of tile regions, tags each crop, prefixes shared positive text, and returns the SEGS together with their matching conditioning batch.
## License & Acknowledgements
The external LLM nodes use a configured OpenAI-compatible provider. **Tag SEGS w/ External LLM** sends each region crop to a vision-capable model and returns aligned conditioning. **External LLM Prompt** sends system and user prompts and returns the response as text, with an optional image for models that support vision.
**SimpleSyrup** is licensed under the GNU Affero General Public License v3.0 or later (**AGPL-3.0-or-later**). Please read the full [LICENSE](LICENSE) included with this repo.
## Images, masks, latents, and sampler extras
AGPL-3.0-or-later is a strong copyleft license. If you convey SimpleSyrup or a modified version, you must provide the corresponding source; and if you let users interact with a modified version over a network, you must offer those users the corresponding source for that modified version.
**Load Image List** loads files in selection order as separate image list items, so each image keeps its own dimensions. **Load Mask Batch** loads same-sized files as one `BHW` mask batch and applies the selected channel consistently to every file.
**Resize Image to Target** handles stretch, keep-aspect, crop, and pad modes. It supports divisibility rounding, anchored crop and pad placement, chunked batches, paired masks, and GPU Lanczos through TorchLanc.
**VAE Encode (Options)** and **VAE Decode (Options)** put the normal and tiled VAE paths behind one explicit tiling control, including spatial and temporal tile settings where Comfy supports them.
**Simple VAE Encode** can reuse the source latent when the graph proves that its image came directly from an unmodified `VAEDecode`. **Upscale Latent From Image** uses the same provenance to find and resize the original latent. Loading, editing, cropping, detailing, or resizing the image breaks that provenance. These nodes follow the graph instead of trying to identify a latent from the finished tensor.
**KSampler (Extras)** adds the A1111/k-diffusion-style `euler_a_a1111` sampler, AYS SD1 and SDXL schedules, GITS, `automatic_a1111`, and the RES4LYF beta57 and `bong_tangent` schedules. Its sampler dropdown includes 118 RES4LYF methods, including `exponential/ddim`. These methods are also available in the contextual, tiled, and Attention Coupling KSamplers. RES4LYF methods use their upstream default initial noise; Comfy samplers keep Comfy's normal noise path.
**Seed Variation** patches a MODEL so Comfy-native samplers mix their normal initial noise toward a second deterministic seed. Strength `0` keeps the sampler seed unchanged, while strength `1` uses variation-seed initial noise. Ancestral and SDE samplers continue to use the sampler seed for additional noise introduced after initialization.
The remaining utilities are **Latent Diagnostics**, **Scale Factor**, and **Seed**. Latent Diagnostics reports the latent shape, dtype, device, and tiled-sampling compatibility while passing it through unchanged.
## Settings and optional integrations
SimpleSyrup adds three ComfyUI settings:
- **SimpleSyrup: Show downloadable models in loader dropdowns** controls whether known downloadable SAM, GroundingDINO, ViTMatte, and WD14 choices appear before they are installed.
- **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, 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.
SimpleSyrup currently interoperates with:
- [ComfyUI Prompt Control](https://github.com/asagi4/comfyui-prompt-control) for scheduled prompts and regional LoRA hooks.
- ComfyUI Impact Pack through compatible `SEGS` values.
- ComfyUI Layer Style Advance through its `LS_SAM_MODELS` bundle.
## License, acknowledgements, and research
**SimpleSyrup** is licensed under the GNU Affero General Public License v3.0 or later (**AGPL-3.0-or-later**). Please read the full [LICENSE](LICENSE) included with this repository.
AGPL-3.0-or-later is a strong copyleft license. If you convey SimpleSyrup or a modified version, you must provide the corresponding source. If users interact with a modified version over a network, you must offer those users the corresponding source for that version.
The vendored RES4LYF license copy includes its upstream commercial-service paragraph before the GNU AGPL v3 text. Read the [RES4LYF license copy](third_party/licenses/res4lyf.LICENSE.txt) and [third-party notices](third_party/NOTICE.md) for the terms and provenance recorded with that code.
SimpleSyrup owes a lot to other projects:
- [ComfyUI Impact Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack) for the SEGS workflow vocabulary and detailer shape this pack is heavily modeled around.
- [ADetailer](https://github.com/Bing-su/adetailer) for the inline `[SEP]` per-segment prompt workflow I missed from WebUI.
- [ComfyUI Layer Style Advance](https://github.com/chflame163/ComfyUI_LayerStyle_Advance) for the SAM workflow surface this pack interoperates with.
- [Tiled Diffusion & VAE for AUTOMATIC1111](https://github.com/pkuliyi2015/multidiffusion-upscaler-for-automatic1111) for practical tiled diffusion and Mixture of Diffusers behavior.
- [RES4LYF](https://github.com/ClownsharkBatwing/RES4LYF) for the beta57 scheduler preset reimplemented here.
- [ComfyUI](https://github.com/Comfy-Org/ComfyUI) provides the engine and graph ecosystem this pack runs on.
- [ComfyUI Impact Pack](https://github.com/ltdrdata/ComfyUI-Impact-Pack) established the SEGS workflow vocabulary and detailer structure used here.
- [ADetailer](https://github.com/Bing-su/adetailer) is where the inline `[SEP]` per-segment prompt workflow came from.
- [ComfyUI Prompt Control](https://github.com/asagi4/comfyui-prompt-control) provides the scheduled prompt and LoRA-hook behavior used by the optional integration.
- [ComfyUI Layer Style Advance](https://github.com/chflame163/ComfyUI_LayerStyle_Advance) provides the SAM model bundle SimpleSyrup can adapt.
- [Tiled Diffusion & VAE for AUTOMATIC1111](https://github.com/pkuliyi2015/multidiffusion-upscaler-for-automatic1111) informed the practical tiled diffusion and Mixture of Diffusers behavior reimplemented here.
- [RES4LYF](https://github.com/ClownsharkBatwing/RES4LYF) by ClownsharkBatwing and contributors provides the Runge-Kutta and exponential sampler methods included here, along with the `bong_tangent` schedule and the beta57 preset.
- [ComfyUI-ppm](https://github.com/pamparamm/ComfyUI-ppm) by pamparamm provides the ModelPatcher-based NegPiP behavior adapted here and builds on the [ComfyUI port](https://github.com/laksjdjf/cd-tuner_negpip-ComfyUI) by laksjdjf and the [original WebUI implementation](https://github.com/hako-mikan/sd-webui-negpip) by hako-mikan.
SimpleSyrup also vendors or reimplements selected third-party behavior for SAM-HQ, MobileSAM, GroundingDINO, AUTOMATIC1111 sampler behavior, k-diffusion, and tiled diffusion behavior. See [third_party/NOTICE.md](third_party/NOTICE.md) for the full third-party notices.
SimpleSyrup also vendors or reimplements selected third-party behavior for SAM-HQ, MobileSAM, GroundingDINO, AUTOMATIC1111 sampler behavior, k-diffusion, and tiled diffusion. See [third_party/NOTICE.md](third_party/NOTICE.md) for the complete notices.
### Research Citations
### Research citations
SimpleSyrup's tiled diffusion behavior is based on ideas from MultiDiffusion and Mixture of Diffusers.
SimpleSyrup's tiled diffusion behavior builds on MultiDiffusion and Mixture of Diffusers. Contextual Diffusion was developed independently and was later found to share a related multiscale residual principle with Upsample Guidance.
```bibtex
@article{bar2023multidiffusion,
@@ -188,9 +219,17 @@ SimpleSyrup's tiled diffusion behavior is based on ideas from MultiDiffusion and
}
```
```bibtex
@article{hwang2024upsample,
title={Upsample Guidance: Scale Up Diffusion Models without Training},
author={Hwang, Juno and Park, Yong-Hyun and Jo, Youngjung},
journal={arXiv preprint arXiv:2404.01709},
year={2024}
}
```
## From the Developer 💖
- **Buy Me a Coffee**: You can help fuel more projects like this at my [Ko-fi page](https://ko-fi.com/artificial_sweetener).
- **My Website & Socials**: See my art, poetry, and other dev updates at [artificialsweetener.ai](https://artificialsweetener.ai).
- **My Website & Socials**: See my art, poetry, research notes, and other development updates at [artificialsweetener.ai](https://artificialsweetener.ai).
- **If you like this project**, it would mean a lot to me if you gave me a star here on GitHub!! ⭐
+22 -6
View File
@@ -12,11 +12,24 @@ from . import simple_syrup as _simple_syrup_package
sys.modules.setdefault("simple_syrup", _simple_syrup_package)
from .simple_syrup.nodes import ( # noqa: E402
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
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.settings_routes import register_settings_routes # noqa: E402
WEB_DIRECTORY = "./web/dist"
@@ -40,10 +53,13 @@ async def comfy_entrypoint() -> object:
register_settings_routes()
register_comfy_safetensors_dtypes()
register_quant_cache_routes()
register_external_llm_routes()
register_mask_batch_preview_routes()
register_attention_region_prompt_handler()
__all__ = [
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"WEB_DIRECTORY",
"comfy_entrypoint",
]
+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"
+58
View File
@@ -0,0 +1,58 @@
schema_version = 1
review_by = 2027-03-31
fingerprint = "sha256:a71fe87163eb7585fafbca08b585954be79abd884e3a1df38bc041c2bc934764"
cohesive_paths = [
"simple_syrup/masking/prompt_segs_with_sam_service.py",
"simple_syrup/nodes/prompt_segs_with_sam.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 = []
+67
View File
@@ -0,0 +1,67 @@
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-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.1.0",
"version": "1.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "simple-syrup-comfyui",
"version": "1.1.0",
"version": "1.13.0",
"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.1.0",
"version": "1.13.0",
"private": true,
"license": "AGPL-3.0-or-later",
"type": "module",
+21 -2
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "SimpleSyrup"
description = "Workflow-focused ComfyUI extensions for image generation."
version = "1.1.0"
version = "1.13.0"
license = "AGPL-3.0-or-later"
license-files = ["LICENSE"]
requires-python = ">=3.11"
@@ -36,6 +36,7 @@ line-length = 88
target-version = "py311"
extend-exclude = [
"simple_syrup/third_party/groundingdino_runtime",
"simple_syrup/third_party/res4lyf_runtime",
"simple_syrup/third_party/sam_hq_runtime",
]
@@ -43,8 +44,11 @@ extend-exclude = [
select = ["E", "F", "I", "UP", "B", "C4", "ANN"]
ignore = ["ANN401"]
[tool.ruff.lint.isort]
known-first-party = ["simple_syrup"]
[tool.mypy]
python_version = "3.11"
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
@@ -53,8 +57,10 @@ check_untyped_defs = true
no_implicit_optional = true
strict_equality = true
explicit_package_bases = true
mypy_path = ["tests"]
exclude = [
"simple_syrup/third_party/groundingdino_runtime",
"simple_syrup/third_party/res4lyf_runtime",
"simple_syrup/third_party/sam_hq_runtime",
]
@@ -62,6 +68,19 @@ exclude = [
module = ["comfy.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["simple_syrup.third_party.res4lyf_runtime.*"]
follow_imports = "skip"
[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",
]
filterwarnings = [
"error",
"ignore:builtin type SwigPyPacked has no __module__ attribute:DeprecationWarning",
"ignore:builtin type SwigPyObject has no __module__ attribute:DeprecationWarning",
]
+3
View File
@@ -6,3 +6,6 @@ timm>=0.6.13
addict>=2.4.0
yapf>=0.43.0
huggingface-hub>=0.34.0
keyring>=25.0.0
mpmath>=1.3.0
PyWavelets>=1.6.0
+1 -1
View File
@@ -6,6 +6,6 @@
from __future__ import annotations
__version__ = "1.1.0"
__version__ = "1.13.0"
__all__: list[str] = ["__version__"]
+118
View File
@@ -0,0 +1,118 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define versioned, quality-aware Anima quantization profiles."""
from __future__ import annotations
import re
from dataclasses import dataclass
from .model_quantization import (
QuantizationFormat,
QuantizationProfile,
TensorDescriptor,
)
ORIGINAL_PROFILE = QuantizationProfile("original", "Original", 2, frozenset())
FP8_E4M3_PROFILE = QuantizationProfile(
"fp8-e4m3",
"FP8 E4M3",
2,
frozenset({QuantizationFormat.FP8_E4M3}),
)
FP8_E5M2_PROFILE = QuantizationProfile(
"fp8-e5m2",
"FP8 E5M2",
2,
frozenset({QuantizationFormat.FP8_E5M2}),
)
MXFP8_PROFILE = QuantizationProfile(
"mxfp8",
"MXFP8",
2,
frozenset({QuantizationFormat.MXFP8}),
)
NVFP4_MIXED_PROFILE = QuantizationProfile(
"nvfp4-mixed",
"NVFP4 (Mixed)",
3,
frozenset({QuantizationFormat.FP8_E4M3, QuantizationFormat.NVFP4}),
)
_PROFILES = (
ORIGINAL_PROFILE,
FP8_E4M3_PROFILE,
FP8_E5M2_PROFILE,
MXFP8_PROFILE,
NVFP4_MIXED_PROFILE,
)
_MAIN_BLOCK_PATTERN = re.compile(
r"(?:^|\.)(?:net|diffusion_model)\.blocks\.(?P<index>\d+)\."
)
_PROTECTED_BLOCKS = {0, 1, 27}
_FLOAT_DTYPES = {"F16", "BF16", "F32", "F64"}
@dataclass(frozen=True)
class AnimaQuantizationRecipe:
"""Assign formats only within Anima's quality-safe DiT block envelope."""
model_family: str = "Anima"
version: int = 2
@property
def profiles(self) -> tuple[QuantizationProfile, ...]:
"""Return Anima's stable workflow-facing profile order."""
return _PROFILES
def profile_from_selection(self, selection: str) -> QuantizationProfile:
"""Parse one current workflow selection into its profile."""
for profile in self.profiles:
if selection in (profile.label, profile.profile_id):
return profile
valid = ", ".join(profile.label for profile in self.profiles)
raise ValueError(f"quantization profile must be one of: {valid}.")
def policy_for(
self,
tensor: TensorDescriptor,
profile: QuantizationProfile,
) -> QuantizationFormat | None:
"""Return Anima's per-tensor format while preserving sensitive layers."""
if profile not in self.profiles:
raise ValueError(
f"Unknown Anima quantization profile '{profile.profile_id}'."
)
if profile.is_original or not _is_matrix_weight(tensor):
return None
if "llm_adapter" in tensor.name or "adaln_modulation" in tensor.name:
return None
block_match = _MAIN_BLOCK_PATTERN.search(tensor.name)
if block_match is None:
return None
if int(block_match.group("index")) in _PROTECTED_BLOCKS:
return None
if profile.profile_id == NVFP4_MIXED_PROFILE.profile_id:
if "v_proj" in tensor.name or ".mlp." in tensor.name:
return QuantizationFormat.FP8_E4M3
if any(
projection in tensor.name
for projection in ("q_proj", "k_proj", "output_proj")
):
return QuantizationFormat.NVFP4
return None
return next(iter(profile.required_formats))
def _is_matrix_weight(tensor: TensorDescriptor) -> bool:
"""Return whether a tensor is an eligible floating-point matrix weight."""
return (
tensor.dtype_name in _FLOAT_DTYPES
and len(tensor.shape) == 2
and tensor.name.endswith(".weight")
)
+15
View File
@@ -0,0 +1,15 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Parse explicit attention concepts without interpreting prompt language."""
from __future__ import annotations
def parse_attention_concepts(value: str) -> tuple[str, ...]:
"""Return canonical concepts separated only by vertical bars."""
if not isinstance(value, str):
raise TypeError("Attention concepts must be text.")
return tuple(part.strip() for part in value.split("|") if part.strip())
@@ -0,0 +1,20 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define validated Attention Coupling preparation data."""
from __future__ import annotations
from dataclasses import dataclass
from .raw_regional_attention import RawRegionalAttentionPlan
@dataclass(frozen=True, slots=True)
class AttentionCouplingPreparation:
"""Retain the full raw plan and base-only ordinary sampler inputs."""
plan: RawRegionalAttentionPlan
positive: object
negative: object
@@ -0,0 +1,46 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Classify complete Attention Coupling requests before runtime preparation."""
from __future__ import annotations
from enum import StrEnum
from .conditioning_batch import ConditioningBatch
class AttentionCouplingRequestMode(StrEnum):
"""Select ordinary sampling or complete regional Attention Coupling."""
BYPASS = "bypass"
ACTIVE = "active"
def classify_attention_coupling_request(
*,
positive: object,
negative: object,
region_masks: object | None,
) -> AttentionCouplingRequestMode:
"""Return the execution mode or reject a partial regional request."""
has_conditioning_batch = isinstance(positive, ConditioningBatch) or isinstance(
negative,
ConditioningBatch,
)
has_region_masks = region_masks is not None
if not has_conditioning_batch and not has_region_masks:
return AttentionCouplingRequestMode.BYPASS
if has_conditioning_batch and has_region_masks:
return AttentionCouplingRequestMode.ACTIVE
if has_conditioning_batch:
raise ValueError(
"Attention Coupling conditioning batches require region_masks. "
"Connect ordered masks or use ordinary CONDITIONING on both inputs."
)
raise ValueError(
"Attention Coupling region_masks require a CONDITIONING_BATCH on the "
"positive or negative input. Disconnect the masks for ordinary sampling."
)
+30
View File
@@ -0,0 +1,30 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Resolve explicit two-dimensional geometry for flattened attention maps."""
from __future__ import annotations
import math
def factor_spatial_geometry(
token_count: int, *, target_aspect: float
) -> tuple[int, int]:
"""Return the token factor pair nearest the supplied positive aspect ratio."""
if type(token_count) is not int or token_count < 1:
raise ValueError("Attention spatial token count must be positive.")
if target_aspect <= 0.0:
raise ValueError("Attention target aspect ratio must be positive.")
candidates: list[tuple[float, int, int]] = []
for height in range(1, math.isqrt(token_count) + 1):
if token_count % height:
continue
width = token_count // height
for candidate_height, candidate_width in ((height, width), (width, height)):
error = abs(math.log((candidate_width / candidate_height) / target_aspect))
candidates.append((error, candidate_height, candidate_width))
_error, height, width = min(candidates)
return height, width
@@ -0,0 +1,175 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define immutable requests and plans for attention-region capture."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from .attention_spatial_transform import AttentionSpatialTransform
from .graph_provenance import GraphLink
class AttentionRegionRequestKind(StrEnum):
"""Identify one public attention-region operation."""
CONCEPT_SEGS = "concept_segs"
ALL_PROMPT_SEGS = "all_prompt_segs"
REGION_MASK = "region_mask"
MASKED_CONDITIONING = "masked_conditioning"
class AttentionCaptureProfile(StrEnum):
"""Select the density of attention observations retained during sampling."""
FAST = "fast"
BALANCED = "balanced"
EXHAUSTIVE = "exhaustive"
class AttentionEvidenceMode(StrEnum):
"""Select honest inspection or derived concept-isolation evidence."""
CONCEPT = "concept isolation"
RAW = "raw attention"
@dataclass(frozen=True, slots=True)
class AttentionRegionControls:
"""Hold validated attention-native capture and region-shaping controls."""
capture_start: float
capture_end: float
minimum_strength: float
minimum_consensus: float
split_sensitivity: float
minimum_region_size: int
profile: AttentionCaptureProfile
instance_recall: float = 0.65
geometry_recall: float = 0.85
keep_only: int = 0
keep_by: str = "largest size"
combine_segs: bool = False
matte_solidity: float = 0.0
edge_feather: int = 8
evidence_mode: AttentionEvidenceMode = AttentionEvidenceMode.CONCEPT
def __post_init__(self) -> None:
"""Require normalized ranges and a non-empty capture interval."""
normalized = (
self.capture_start,
self.capture_end,
self.minimum_strength,
self.minimum_consensus,
self.split_sensitivity,
self.instance_recall,
self.geometry_recall,
self.matte_solidity,
)
if any(
isinstance(value, bool) or not isinstance(value, int | float)
for value in normalized
):
raise TypeError("Attention-region controls must be real numbers.")
if not 0.0 <= self.capture_start < self.capture_end <= 1.0:
raise ValueError("Attention capture start must be below end within 0..1.")
if not 0.0 <= self.minimum_strength <= 1.0:
raise ValueError("Minimum attention strength must be within 0..1.")
if not 0.0 <= self.minimum_consensus <= 1.0:
raise ValueError("Minimum attention consensus must be within 0..1.")
if not 0.0 <= self.split_sensitivity <= 1.0:
raise ValueError("Attention split sensitivity must be within 0..1.")
if not 0.0 <= self.instance_recall <= 1.0:
raise ValueError("Attention instance recall must be within 0..1.")
if not 0.0 <= self.geometry_recall <= 1.0:
raise ValueError("Attention geometry recall must be within 0..1.")
if type(self.minimum_region_size) is not int or self.minimum_region_size < 1:
raise ValueError("Minimum attention region size must be positive.")
if type(self.keep_only) is not int or self.keep_only < 0:
raise ValueError("Attention keep_only must be non-negative.")
if self.keep_by not in ("largest size", "highest confidence"):
raise ValueError("Attention keep_by has an invalid policy.")
if type(self.combine_segs) is not bool:
raise TypeError("Attention combine_segs must be boolean.")
if not 0.0 <= self.matte_solidity <= 1.0:
raise ValueError("Attention matte solidity must be within 0..1.")
if type(self.edge_feather) is not int or self.edge_feather < 0:
raise ValueError("Attention edge feather must be non-negative.")
if not isinstance(self.profile, AttentionCaptureProfile):
raise TypeError("Attention capture profile has an invalid type.")
if not isinstance(self.evidence_mode, AttentionEvidenceMode):
raise TypeError("Attention evidence mode has an invalid type.")
@dataclass(frozen=True, slots=True)
class AttentionRegionRequest:
"""Bind one public node request to its queries and capture controls."""
node_id: str
kind: AttentionRegionRequestKind
queries: tuple[str, ...]
controls: AttentionRegionControls
sampler_stage: int = 1
spatial_transforms: tuple[AttentionSpatialTransform, ...] = ()
def __post_init__(self) -> None:
"""Require stable node identity and canonical non-empty query strings."""
if not self.node_id.strip():
raise ValueError("Attention-region request node id cannot be empty.")
if not isinstance(self.kind, AttentionRegionRequestKind):
raise TypeError("Attention-region request kind has an invalid type.")
if any(not query or query != query.strip() for query in self.queries):
raise ValueError("Attention-region queries must be canonical strings.")
if self.kind is AttentionRegionRequestKind.ALL_PROMPT_SEGS:
if self.queries:
raise ValueError(
"All-prompt attention requests cannot contain queries."
)
elif not self.queries:
raise ValueError("Concept and mask attention requests require concepts.")
if type(self.sampler_stage) is not int or self.sampler_stage < -1:
raise ValueError("Attention sampler stage must be -1 or greater.")
if any(
not isinstance(transform, AttentionSpatialTransform)
for transform in self.spatial_transforms
):
raise TypeError("Attention request spatial transforms are invalid.")
@dataclass(frozen=True, slots=True)
class AttentionCapturePlan:
"""Describe one coalesced sampler capture and its graph rewrite authority."""
sampler_node_id: str
model_owner_node_id: str
model_input_name: str
model_link: GraphLink
positive_link: GraphLink
requests: tuple[AttentionRegionRequest, ...]
prompt_text: str | None = None
clip_link: GraphLink | None = None
source_aspect: float | None = None
def __post_init__(self) -> None:
"""Require canonical unique requests and complete graph-edge identity."""
if not self.sampler_node_id or not self.model_owner_node_id:
raise ValueError("Attention capture plan node ids cannot be empty.")
if not self.model_input_name:
raise ValueError("Attention capture plan model input cannot be empty.")
if self.source_aspect is not None and self.source_aspect <= 0.0:
raise ValueError("Attention capture source aspect must be positive.")
request_ids = tuple(request.node_id for request in self.requests)
if not request_ids or request_ids != tuple(sorted(set(request_ids))):
raise ValueError("Attention capture requests must be unique and ordered.")
@property
def capture_node_id(self) -> str:
"""Return a collision-resistant deterministic injected node id."""
return f"__simple_syrup_attention_capture__{self.sampler_node_id}"
@@ -0,0 +1,21 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define rendered attention evidence before component and matte shaping."""
from __future__ import annotations
from dataclasses import dataclass
import torch
@dataclass(frozen=True, slots=True)
class AttentionConceptEvidence:
"""Hold one concept's alpha, support, and confidence evidence."""
label: str
alpha: torch.Tensor
support: torch.Tensor
confidence: torch.Tensor
@@ -0,0 +1,198 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Model prompt-token spans and compact captured attention observations."""
from __future__ import annotations
from dataclasses import dataclass
import torch
from .attention_spatial_transform import AttentionSpatialTransform
from .regional_model_capabilities import RegionalModelFamily
@dataclass(frozen=True, slots=True)
class AttentionTokenSpan:
"""Bind a readable prompt occurrence to exact conditioning token positions."""
label: str
occurrence: int
token_indices: tuple[int, ...]
head_token_indices: tuple[int, ...] = ()
def __post_init__(self) -> None:
"""Require canonical labels and ordered non-negative token indices."""
if not self.label or self.label != self.label.strip():
raise ValueError("Attention token span label must be canonical.")
if type(self.occurrence) is not int or self.occurrence < 1:
raise ValueError("Attention token span occurrence must be positive.")
if (
not self.token_indices
or self.token_indices != tuple(sorted(set(self.token_indices)))
or self.token_indices[0] < 0
):
raise ValueError("Attention token indices must be ordered and unique.")
if self.head_token_indices and (
self.head_token_indices != tuple(sorted(set(self.head_token_indices)))
or not set(self.head_token_indices).issubset(self.token_indices)
):
raise ValueError("Attention head tokens must be an ordered span subset.")
@property
def semantic_head_indices(self) -> tuple[int, ...]:
"""Return explicit noun-head positions or a safe final-token fallback."""
return self.head_token_indices or self.token_indices[-1:]
@property
def display_label(self) -> str:
"""Disambiguate repeated concepts while keeping first labels concise."""
return (
self.label if self.occurrence == 1 else f"{self.label} #{self.occurrence}"
)
@dataclass(frozen=True, slots=True)
class AttentionTokenCatalog:
"""Hold readable prompt spans and conditioning sequence length."""
sequence_length: int
spans: tuple[AttentionTokenSpan, ...]
token_ids: tuple[object, ...]
def __post_init__(self) -> None:
"""Require all spans to fit the captured conditioning sequence."""
if type(self.sequence_length) is not int or self.sequence_length < 1:
raise ValueError("Attention token sequence length must be positive.")
if len(self.token_ids) != self.sequence_length:
raise ValueError("Attention token ids must match the sequence length.")
if any(
index >= self.sequence_length
for span in self.spans
for index in span.token_indices
):
raise ValueError("Attention token span exceeds its conditioning sequence.")
def exact_matches(self, query: str) -> tuple[AttentionTokenSpan, ...]:
"""Return every prompt occurrence whose normalized label equals a query."""
normalized = _normalized_label(query)
return tuple(
span for span in self.spans if _normalized_label(span.label) == normalized
)
@dataclass(frozen=True, slots=True)
class CapturedAttentionMap:
"""Store one head-aggregated token map and its denoising observation identity."""
label: str
values: torch.Tensor
progress: float
layer_key: str
batch_index: int = 0
confidence: float = 1.0
spatial_height: int | None = None
spatial_width: int | None = None
spatial_transforms: tuple[AttentionSpatialTransform, ...] = ()
concept_values: torch.Tensor | None = None
uniform_probability: float = 0.0
model_family: RegionalModelFamily = RegionalModelFamily.STANDARD_UNET
def __post_init__(self) -> None:
"""Require a finite CPU spatial vector and normalized progress."""
if not self.label:
raise ValueError("Captured attention map label cannot be empty.")
if (
not isinstance(self.values, torch.Tensor)
or self.values.device.type != "cpu"
or self.values.ndim != 1
or self.values.numel() < 1
or not self.values.is_floating_point()
or not torch.isfinite(self.values).all().item()
):
raise ValueError("Captured attention values must be a finite CPU vector.")
if not 0.0 <= self.progress <= 1.0:
raise ValueError("Captured attention progress must be within 0..1.")
if not self.layer_key:
raise ValueError("Captured attention layer key cannot be empty.")
if type(self.batch_index) is not int or self.batch_index < 0:
raise ValueError("Captured attention batch index must be non-negative.")
if not 0.0 <= self.confidence <= 1.0:
raise ValueError("Captured attention confidence must be within 0..1.")
if (self.spatial_height is None) != (self.spatial_width is None):
raise ValueError("Captured attention geometry must be complete or absent.")
if self.spatial_height is not None and (
type(self.spatial_height) is not int
or self.spatial_height < 1
or type(self.spatial_width) is not int
or self.spatial_width < 1
or self.spatial_height * self.spatial_width != int(self.values.numel())
):
raise ValueError("Captured attention geometry must match its values.")
if any(
not isinstance(transform, AttentionSpatialTransform)
for transform in self.spatial_transforms
):
raise TypeError("Captured attention spatial transforms are invalid.")
if self.concept_values is not None and (
not isinstance(self.concept_values, torch.Tensor)
or self.concept_values.device.type != "cpu"
or self.concept_values.shape != self.values.shape
or not self.concept_values.is_floating_point()
or not torch.isfinite(self.concept_values).all().item()
):
raise ValueError(
"Captured concept evidence must match its finite CPU attention map."
)
if (
isinstance(self.uniform_probability, bool)
or not isinstance(self.uniform_probability, int | float)
or not 0.0 <= float(self.uniform_probability) <= 1.0
):
raise ValueError("Captured uniform probability must be within 0..1.")
if not isinstance(self.model_family, RegionalModelFamily):
raise TypeError("Captured attention model family has an invalid type.")
@dataclass(frozen=True, slots=True)
class OpenVocabularyContext:
"""Hold one query's encoded SDXL context and semantic token positions."""
label: str
values: torch.Tensor
token_indices: tuple[int, ...]
def __post_init__(self) -> None:
"""Require one finite CPU context with valid unique token positions."""
if not self.label.strip() or self.label != self.label.strip():
raise ValueError("Open-vocabulary labels must be canonical strings.")
if self.values.ndim != 3 or int(self.values.shape[0]) != 1:
raise ValueError("Open-vocabulary context must have shape 1xTxC.")
if self.values.device.type != "cpu" or not torch.isfinite(self.values).all():
raise ValueError("Open-vocabulary context must be finite CPU storage.")
if not self.token_indices or self.token_indices != tuple(
sorted(set(self.token_indices))
):
raise ValueError(
"Open-vocabulary token positions must be unique and ordered."
)
if any(
index < 0 or index >= int(self.values.shape[1])
for index in self.token_indices
):
raise ValueError("Open-vocabulary token positions exceed their context.")
def _normalized_label(value: str) -> str:
"""Normalize human prompt labels without changing tokenizer semantics."""
return " ".join(value.casefold().replace("_", " ").split())
@@ -0,0 +1,78 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Model ordered sampling stages along one spatial provenance path."""
from __future__ import annotations
from dataclasses import dataclass
from .attention_spatial_transform import AttentionSpatialTransform
from .graph_provenance import GraphLink
@dataclass(frozen=True, slots=True)
class AttentionSamplerStage:
"""Describe one sampler's patch and conditioning authority."""
sampler_node_id: str
model_owner_node_id: str
model_link: GraphLink
positive_link: GraphLink
upstream_link: GraphLink
upstream_kind: str
forward_transforms: tuple[AttentionSpatialTransform, ...] = ()
source_aspect: float | None = None
capture_supported: bool = True
unsupported_reason: str | None = None
def __post_init__(self) -> None:
"""Require unsupported stages to explain why capture cannot be projected."""
if self.capture_supported and self.unsupported_reason is not None:
raise ValueError("Supported attention sampler stages cannot have a reason.")
if not self.capture_supported and not self.unsupported_reason:
raise ValueError("Unsupported attention sampler stages require a reason.")
if self.source_aspect is not None and self.source_aspect <= 0.0:
raise ValueError("Attention sampler source aspect must be positive.")
@dataclass(frozen=True, slots=True)
class AttentionSamplerSelection:
"""Bind a selected stage to its one-based chronological position."""
stage: AttentionSamplerStage
stage_number: int
stage_count: int
was_clamped: bool
@dataclass(frozen=True, slots=True)
class AttentionSamplerLineage:
"""Hold sampling stages ordered from oldest to direct provenance."""
stages: tuple[AttentionSamplerStage, ...]
def __post_init__(self) -> None:
"""Require at least one uniquely identified stage."""
identities = tuple(stage.sampler_node_id for stage in self.stages)
if not identities or len(set(identities)) != len(identities):
raise ValueError("Attention sampler lineage must contain unique stages.")
def select(self, requested_stage: int) -> AttentionSamplerSelection:
"""Resolve one-based selection with 0/-1 aliases for direct provenance."""
if type(requested_stage) is not int or requested_stage < -1:
raise ValueError("Attention sampler stage must be -1 or greater.")
count = len(self.stages)
if requested_stage in (-1, 0):
return AttentionSamplerSelection(self.stages[-1], count, count, False)
selected_number = min(requested_stage, count)
return AttentionSamplerSelection(
self.stages[selected_number - 1],
selected_number,
count,
requested_stage > count,
)
@@ -0,0 +1,70 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Describe graph-visible full-canvas transformations for attention masks."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
_ANCHORS = frozenset(
{
"center",
"top-left",
"top",
"top-right",
"left",
"right",
"bottom-left",
"bottom",
"bottom-right",
}
)
class AttentionSpatialTransformKind(StrEnum):
"""Identify a supported mask-coordinate transformation."""
RESIZE = "resize"
FIT_RESIZE = "fit_resize"
SCALE = "scale"
COVER_CROP = "cover_crop"
FIT_PAD = "fit_pad"
@dataclass(frozen=True, slots=True)
class AttentionSpatialTransform:
"""Hold validated resize, crop, or pad parameters from one graph node."""
kind: AttentionSpatialTransformKind
width: int | None = None
height: int | None = None
scale: float | None = None
anchor: str = "center"
divisible_by: int = 1
def __post_init__(self) -> None:
"""Require complete parameters for the selected transformation kind."""
if self.anchor not in _ANCHORS:
raise ValueError("Attention spatial transform anchor is invalid.")
if self.kind is AttentionSpatialTransformKind.SCALE:
if self.scale is None or self.scale <= 0.0:
raise ValueError("Attention scale transform requires a positive scale.")
if self.width is not None or self.height is not None:
raise ValueError("Attention scale transform cannot contain a size.")
if self.divisible_by != 1:
raise ValueError("Attention scale transform cannot set divisibility.")
return
if (
type(self.width) is not int
or self.width < 1
or type(self.height) is not int
or self.height < 1
or self.scale is not None
):
raise ValueError("Attention spatial transform requires a positive size.")
if type(self.divisible_by) is not int or self.divisible_by < 1:
raise ValueError("Attention spatial divisibility must be positive.")
+14 -7
View File
@@ -6,7 +6,6 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any, TypeAlias
@@ -40,13 +39,21 @@ class ConditioningBatch:
return ConditioningBatch((*self.entries, conditioning))
def split_prompt_batch(text: str, separator: str = "[SEP]") -> tuple[str, ...]:
"""Split prompt text into ordered chunks using a configurable separator."""
def batch_conditioning(
values: tuple[Conditioning | ConditioningBatch, ...],
) -> ConditioningBatch:
"""Flatten conditioning values and batches into one ordered batch."""
if separator == "":
raise ValueError("separator must not be empty.")
pattern = rf"\s*{re.escape(separator)}\s*"
return tuple(re.split(pattern, text))
if not values:
raise ValueError("Batch Region Conditioning requires one or more inputs.")
entries: list[Conditioning] = []
for value in values:
if isinstance(value, ConditioningBatch):
entries.extend(value.entries)
else:
entries.append(value)
return ConditioningBatch(tuple(entries))
def select_conditioning(
@@ -0,0 +1,90 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own immutable authored and model-converted conditioning schedule bounds."""
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class ConditioningScheduleRange:
"""Retain optional authored percentages and converted sigma boundaries."""
start_percent: float | None
end_percent: float | None
timestep_start: float | None
timestep_end: float | None
def __post_init__(self) -> None:
"""Validate exact optional bounds without inventing absent metadata."""
start_percent = _optional_finite_float(
self.start_percent,
name="start_percent",
)
end_percent = _optional_finite_float(
self.end_percent,
name="end_percent",
)
timestep_start = _optional_finite_float(
self.timestep_start,
name="timestep_start",
)
timestep_end = _optional_finite_float(
self.timestep_end,
name="timestep_end",
)
for name, value in (
("start_percent", start_percent),
("end_percent", end_percent),
):
if value is not None and not 0.0 <= value <= 1.0:
raise ValueError(f"Conditioning {name} must be in [0, 1].")
effective_start = 0.0 if start_percent is None else start_percent
effective_end = 1.0 if end_percent is None else end_percent
if effective_start > effective_end:
raise ValueError("Conditioning start_percent must not exceed end_percent.")
if (
timestep_start is not None
and timestep_end is not None
and timestep_start < timestep_end
):
raise ValueError(
"Conditioning timestep_start must not be below timestep_end."
)
object.__setattr__(self, "start_percent", start_percent)
object.__setattr__(self, "end_percent", end_percent)
object.__setattr__(self, "timestep_start", timestep_start)
object.__setattr__(self, "timestep_end", timestep_end)
@property
def is_time_invariant(self) -> bool:
"""Report whether this entry remains admitted for the whole trajectory."""
if self.start_percent is None and self.timestep_start is not None:
return False
if self.end_percent is None and self.timestep_end is not None:
return False
effective_start = 0.0 if self.start_percent is None else self.start_percent
effective_end = 1.0 if self.end_percent is None else self.end_percent
return effective_start == 0.0 and effective_end == 1.0
def _optional_finite_float(value: object, *, name: str) -> float | None:
"""Normalize one optional real boundary without accepting booleans."""
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int | float):
raise TypeError(f"Conditioning {name} must be a real number or None.")
normalized = float(value)
if not math.isfinite(normalized):
raise ValueError(f"Conditioning {name} must be finite.")
return normalized
UNBOUNDED_CONDITIONING_SCHEDULE = ConditioningScheduleRange(None, None, None, None)
@@ -0,0 +1,49 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own installed-Comfy-equivalent conditioning schedule admission."""
from __future__ import annotations
import math
from .conditioning_schedule import ConditioningScheduleRange
class ConditioningScheduleSelectionPolicy:
"""Match installed Comfy's inclusive converted-sigma admission policy."""
@staticmethod
def is_active(
schedule: ConditioningScheduleRange,
*,
sigma: float,
) -> bool:
"""Return installed Comfy's inclusive start/end decision."""
if not isinstance(schedule, ConditioningScheduleRange):
raise TypeError("Conditioning selection requires a schedule range.")
current_sigma = normalize_conditioning_sigma(sigma)
if (
schedule.timestep_start is not None
and current_sigma > schedule.timestep_start
):
return False
return not (
schedule.timestep_end is not None and current_sigma < schedule.timestep_end
)
def normalize_conditioning_sigma(value: object) -> float:
"""Normalize one finite real current sigma without accepting booleans."""
if isinstance(value, bool) or not isinstance(value, int | float):
raise TypeError("Conditioning selection sigma must be a real number.")
sigma = float(value)
if not math.isfinite(sigma):
raise ValueError("Conditioning selection sigma must be finite.")
return sigma
CONDITIONING_SCHEDULE_SELECTION_POLICY = ConditioningScheduleSelectionPolicy()
+170
View File
@@ -0,0 +1,170 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Project evaluated latent context windows into lazily materialized SEGS."""
from __future__ import annotations
import math
from collections.abc import Iterable, Iterator, Sequence
from threading import Lock
from typing import TypeAlias, overload
import torch
from .segs import BoundingBox, CropRegion, Segment, SegsHeader
from .tiled_diffusion import TiledDiffusionPlan
ContextSegs: TypeAlias = tuple[SegsHeader, "ContextSegmentSequence"]
class ContextSegmentSequence(Sequence[Segment]):
"""Delay large rectangular mask allocation until a SEGS consumer reads it."""
def __init__(self, windows: Iterable[CropRegion]) -> None:
"""Store deterministic context windows without allocating their masks."""
self._windows = tuple(windows)
self._materialized: tuple[Segment, ...] | None = None
self._lock = Lock()
@property
def windows(self) -> tuple[CropRegion, ...]:
"""Return immutable projected windows without forcing mask allocation."""
return self._windows
@property
def is_materialized(self) -> bool:
"""Report whether a downstream consumer has requested concrete segments."""
return self._materialized is not None
def __len__(self) -> int:
"""Return the number of contexts without materializing masks."""
return len(self._windows)
@overload
def __getitem__(self, index: int) -> Segment: ...
@overload
def __getitem__(self, index: slice) -> tuple[Segment, ...]: ...
def __getitem__(self, index: int | slice) -> Segment | tuple[Segment, ...]:
"""Materialize masks and return one context or a context slice."""
return self._segments()[index]
def __iter__(self) -> Iterator[Segment]:
"""Materialize masks once and iterate contexts in evaluation order."""
return iter(self._segments())
def _segments(self) -> tuple[Segment, ...]:
"""Create full rectangular masks once on first downstream access."""
if self._materialized is not None:
return self._materialized
with self._lock:
if self._materialized is None:
self._materialized = tuple(
_segment_from_window(window, index)
for index, window in enumerate(self._windows, start=1)
)
return self._materialized
def context_segs_from_tile_plan(
plan: TiledDiffusionPlan,
*,
image_height: int,
image_width: int,
) -> ContextSegs:
"""Return lazy rectangular SEGS for every non-global evaluated tile."""
_validate_image_dimensions(image_height, image_width)
windows = tuple(
_project_tile(
x=tile.x,
y=tile.y,
width=tile.width,
height=tile.height,
latent_width=plan.latent_width,
latent_height=plan.latent_height,
image_width=image_width,
image_height=image_height,
)
for tile in plan.tiles
)
return (image_height, image_width), ContextSegmentSequence(windows)
def merge_context_segs(values: Iterable[ContextSegs]) -> ContextSegs:
"""Combine batched context windows without duplicates or mask allocation."""
items = tuple(values)
if not items:
raise ValueError("Context SEGS requires at least one context plan.")
header = items[0][0]
if any(item[0] != header for item in items[1:]):
raise ValueError(
"Context SEGS requires batched image dimensions to match exactly."
)
windows = list(items[0][1].windows)
seen = set(windows)
for _header, segments in items[1:]:
for window in segments.windows:
if window not in seen:
windows.append(window)
seen.add(window)
return header, ContextSegmentSequence(windows)
def _project_tile(
*,
x: int,
y: int,
width: int,
height: int,
latent_width: int,
latent_height: int,
image_width: int,
image_height: int,
) -> CropRegion:
"""Project one latent rectangle outward into integer image coordinates."""
left = math.floor(x * image_width / latent_width)
top = math.floor(y * image_height / latent_height)
right = math.ceil((x + width) * image_width / latent_width)
bottom = math.ceil((y + height) * image_height / latent_height)
return CropRegion(
max(0, min(image_width - 1, left)),
max(0, min(image_height - 1, top)),
max(1, min(image_width, right)),
max(1, min(image_height, bottom)),
)
def _segment_from_window(window: CropRegion, index: int) -> Segment:
"""Materialize one Impact-compatible full rectangular context mask."""
return Segment(
cropped_image=None,
cropped_mask=torch.ones(
(window.height, window.width),
dtype=torch.uint8,
),
confidence=1.0,
crop_region=window,
bbox=BoundingBox(*window),
label=f"context_{index:03d}",
)
def _validate_image_dimensions(height: int, width: int) -> None:
"""Reject image geometry that cannot host projected context rectangles."""
if height < 1 or width < 1:
raise ValueError("Context SEGS image dimensions must be positive.")
+174
View File
@@ -0,0 +1,174 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Plan one bounded global context and one authoritative tiled context set."""
from __future__ import annotations
from dataclasses import dataclass
import torch
from .regional_tiled_diffusion import build_region_constrained_tiled_diffusion_plan
from .segs import NativeSegs
from .segs_tiled_diffusion import build_segs_guided_tiled_diffusion_plan
from .spatial_views import SpatialView, SpatialViewKind
from .tiled_diffusion import TiledDiffusionPlan, build_tiled_diffusion_plan
@dataclass(frozen=True)
class ContextualDiffusionControls:
"""Validate workflow controls for contextual diffusion sampling."""
latent_context_size: int
latent_context_overlap: int
latent_context_batch_size: int
global_weight: float
global_steps: int
global_decay: float
latent_tile_width: int | None = None
latent_tile_height: int | None = None
@property
def tile_width(self) -> int:
"""Use explicit local geometry or the convenience node's context size."""
return self.latent_tile_width or self.latent_context_size
@property
def tile_height(self) -> int:
"""Keep the global context independent of a rectangular local tile."""
return self.latent_tile_height or self.latent_context_size
def validate(self) -> None:
"""Reject controls that cannot produce a stable bounded context plan."""
if self.latent_context_size < 16:
raise ValueError("latent_context_size must be at least 16 latent pixels.")
for value in (self.latent_tile_width, self.latent_tile_height):
if value is not None and (type(value) is not int or value < 16):
raise ValueError(
"Local tile dimensions must be at least 16 latent pixels."
)
if (
not 0
<= self.latent_context_overlap
< min(self.tile_width, self.tile_height)
):
raise ValueError(
"latent_context_overlap must be non-negative and smaller than "
"both local tile dimensions."
)
if self.latent_context_batch_size < 1:
raise ValueError("latent_context_batch_size must be at least 1.")
if not 0.0 <= self.global_weight <= 2.0:
raise ValueError("global_weight must be between 0 and 2.")
if self.global_steps < 0:
raise ValueError("global_steps must be non-negative.")
if not 0.0 <= self.global_decay <= 1.0:
raise ValueError("global_decay must be between 0 and 1.")
@dataclass(frozen=True)
class ContextualDiffusionPlan:
"""Own the global context and sole tiled plan for one latent canvas."""
latent_width: int
latent_height: int
global_view: SpatialView
tile_plan: TiledDiffusionPlan
def build_contextual_diffusion_plan(
*,
latent_width: int,
latent_height: int,
controls: ContextualDiffusionControls,
segs: NativeSegs | None,
region_masks: torch.Tensor | None = None,
segs_canvas: tuple[int, int] | None = None,
) -> ContextualDiffusionPlan:
"""Return a global context plus the regular or SEGS-guided context plan."""
controls.validate()
global_width, global_height = fit_context_shape(
latent_width,
latent_height,
controls.latent_context_size,
)
global_view = SpatialView(
kind=SpatialViewKind.CONTEXTUAL_GLOBAL,
source_x=0,
source_y=0,
source_width=latent_width,
source_height=latent_height,
model_width=global_width,
model_height=global_height,
)
if region_masks is not None:
tile_plan = build_region_constrained_tiled_diffusion_plan(
region_masks=region_masks,
segs=segs,
latent_width=latent_width,
latent_height=latent_height,
tile_width=controls.tile_width,
tile_height=controls.tile_height,
overlap=controls.latent_context_overlap,
tile_batch_size=controls.latent_context_batch_size,
segs_canvas=segs_canvas,
)
elif segs is not None:
tile_plan = build_segs_guided_tiled_diffusion_plan(
segs=segs,
latent_width=latent_width,
latent_height=latent_height,
tile_width=controls.tile_width,
tile_height=controls.tile_height,
overlap=controls.latent_context_overlap,
tile_batch_size=controls.latent_context_batch_size,
segs_canvas=segs_canvas,
)
else:
tile_plan = build_tiled_diffusion_plan(
latent_width=latent_width,
latent_height=latent_height,
tile_width=controls.tile_width,
tile_height=controls.tile_height,
overlap=controls.latent_context_overlap,
tile_batch_size=controls.latent_context_batch_size,
)
return ContextualDiffusionPlan(
latent_width=latent_width,
latent_height=latent_height,
global_view=global_view,
tile_plan=tile_plan,
)
def fit_context_shape(width: int, height: int, max_size: int) -> tuple[int, int]:
"""Fit a rectangle inside one maximum latent dimension without boxing it."""
if width < 1 or height < 1:
raise ValueError("Context source dimensions must be positive.")
if max_size < 1:
raise ValueError("Context maximum size must be positive.")
if max(width, height) <= max_size:
return width, height
scale = max_size / max(width, height)
fitted_width = max(2, round(width * scale))
fitted_height = max(2, round(height * scale))
return _even_at_most(fitted_width, max_size), _even_at_most(
fitted_height,
max_size,
)
def _even_at_most(value: int, maximum: int) -> int:
"""Return a positive even model-context dimension within its maximum."""
bounded = min(maximum, max(2, value))
if bounded % 2 == 0:
return bounded
if bounded == maximum:
return max(2, bounded - 1)
return bounded + 1
+153
View File
@@ -0,0 +1,153 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Domain objects and validation for external LLM provider integration."""
from __future__ import annotations
from dataclasses import dataclass
from typing import ClassVar
from urllib.parse import urlparse
DEFAULT_EXTERNAL_LLM_MAX_TOKENS = 1024
DEFAULT_EXTERNAL_LLM_REASONING_EFFORT = "default"
EXTERNAL_LLM_REASONING_EFFORTS = (
DEFAULT_EXTERNAL_LLM_REASONING_EFFORT,
"high",
"medium",
"low",
"off",
)
class ExternalLLMConfigError(ValueError):
"""Raised when external LLM configuration is invalid."""
class ExternalLLMProviderError(RuntimeError):
"""Raised when an external LLM provider request fails."""
@dataclass(frozen=True)
class ExternalLLMConfig:
"""Validated non-secret external LLM provider configuration."""
base_url: str
cached_models: tuple[str, ...] = ()
default_model: str = ""
def __post_init__(self) -> None:
"""Validate and normalize configuration values."""
normalized_url = normalize_base_url(self.base_url)
models = normalize_model_ids(self.cached_models)
default = self.default_model.strip()
if default and models and default not in models:
raise ExternalLLMConfigError(
"Default external LLM model must be one of the cached models."
)
object.__setattr__(self, "base_url", normalized_url)
object.__setattr__(self, "cached_models", models)
object.__setattr__(self, "default_model", default)
@dataclass(frozen=True)
class ExternalLLMModel:
"""A provider model advertised through an OpenAI-compatible models response."""
id: str
def __post_init__(self) -> None:
"""Reject empty provider model identifiers."""
model_id = self.id.strip()
if not model_id:
raise ExternalLLMConfigError("External LLM model id must not be empty.")
object.__setattr__(self, "id", model_id)
@dataclass(frozen=True)
class ExternalLLMChatRequest:
"""Validated chat completion request fields for an external LLM provider."""
VALID_REASONING_EFFORTS: ClassVar[tuple[str, ...]] = EXTERNAL_LLM_REASONING_EFFORTS
model: str
system_prompt: str
user_prompt: str
max_tokens: int = DEFAULT_EXTERNAL_LLM_MAX_TOKENS
reasoning_effort: str = DEFAULT_EXTERNAL_LLM_REASONING_EFFORT
image_data_url: str | None = None
def __post_init__(self) -> None:
"""Validate prompt request values before provider execution."""
model = self.model.strip()
reasoning_effort = self.reasoning_effort.strip()
if not model:
raise ExternalLLMConfigError("External LLM model must not be empty.")
if not self.user_prompt.strip():
raise ExternalLLMConfigError(
"User prompt must not be empty for external LLM requests."
)
if self.max_tokens < 1:
raise ExternalLLMConfigError("External LLM max tokens must be at least 1.")
if reasoning_effort not in self.VALID_REASONING_EFFORTS:
choices = ", ".join(self.VALID_REASONING_EFFORTS)
raise ExternalLLMConfigError(
f"External LLM reasoning effort must be one of: {choices}."
)
if self.image_data_url is not None and not self.image_data_url.startswith(
"data:image/"
):
raise ExternalLLMConfigError(
"External LLM image input must be an image data URL."
)
object.__setattr__(self, "model", model)
object.__setattr__(self, "reasoning_effort", reasoning_effort)
@dataclass(frozen=True)
class ExternalLLMChatResponse:
"""Assistant response content returned by an external LLM provider."""
content: str
def normalize_base_url(base_url: str) -> str:
"""Return a normalized absolute HTTP(S) endpoint URL."""
candidate = base_url.strip().rstrip("/")
if not candidate:
raise ExternalLLMConfigError(
"Configure an external LLM endpoint in SimpleSyrup settings before "
"using this node."
)
parsed = urlparse(candidate)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ExternalLLMConfigError(
"External LLM endpoint must be an absolute http:// or https:// URL."
)
return candidate
def normalize_model_ids(values: object) -> tuple[str, ...]:
"""Return non-empty de-duplicated model ids in provider order."""
if not isinstance(values, (list, tuple)):
raise ExternalLLMConfigError("External LLM cached models must be a list.")
models: list[str] = []
seen: set[str] = set()
for value in values:
if not isinstance(value, str):
raise ExternalLLMConfigError(
"External LLM cached model ids must be strings."
)
model = value.strip()
if model and model not in seen:
seen.add(model)
models.append(model)
return tuple(models)
+58
View File
@@ -0,0 +1,58 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Classify FLUX model generations from ComfyUI's structural model metadata."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class FluxGeneration(StrEnum):
"""Identify the conditioning generation used by a FLUX diffusion model."""
FLUX = "flux"
FLUX2 = "flux2"
class Flux2TextEncoderProfile(StrEnum):
"""Identify the text-encoder family required by a FLUX.2 architecture."""
DEV = "dev"
KLEIN_4B = "klein_4b"
KLEIN_9B = "klein_9b"
@dataclass(frozen=True)
class FluxModelProfile:
"""Describe a structurally detected FLUX generation and encoder profile."""
generation: FluxGeneration
flux2_text_encoder: Flux2TextEncoderProfile | None = None
FLUX2_CONTEXT_DIMENSIONS: dict[int, Flux2TextEncoderProfile] = {
15_360: Flux2TextEncoderProfile.DEV,
7_680: Flux2TextEncoderProfile.KLEIN_4B,
12_288: Flux2TextEncoderProfile.KLEIN_9B,
}
def classify_flux_profile(
image_model: str | None,
context_input_dimension: int | None,
) -> FluxModelProfile | None:
"""Return the FLUX profile represented by ComfyUI's detected dimensions."""
if image_model == FluxGeneration.FLUX:
return FluxModelProfile(FluxGeneration.FLUX)
if image_model != FluxGeneration.FLUX2:
return None
encoder_profile = (
FLUX2_CONTEXT_DIMENSIONS.get(context_input_dimension)
if context_input_dimension is not None
else None
)
return FluxModelProfile(FluxGeneration.FLUX2, encoder_profile)
@@ -0,0 +1,42 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Select decayed reduced-global authority from denoising timesteps."""
from __future__ import annotations
import torch
class GlobalContextSchedule:
"""Limit whole-image authority to an initial denoising-step fraction."""
def __init__(
self, *, sigmas: torch.Tensor, active_steps: int, decay: float
) -> None:
"""Capture model-evaluation sigmas and the active initial step count."""
step_sigmas = sigmas.detach().to(device="cpu", dtype=torch.float64).flatten()
if step_sigmas.numel() < 2:
raise ValueError(
"Contextual Diffusion requires at least one denoising step."
)
self._step_sigmas = step_sigmas[:-1]
self._active_steps = min(len(self._step_sigmas), max(0, active_steps))
self._decay = decay
def scale_for(self, timestep: object) -> float:
"""Return the decayed global scale for the nearest scheduled step."""
if self._active_steps == 0:
return 0.0
if not isinstance(timestep, torch.Tensor) or timestep.numel() == 0:
raise ValueError(
"Contextual Diffusion timestep must be a non-empty tensor."
)
sigma = timestep.detach().flatten()[0].to(device="cpu", dtype=torch.float64)
step_index = int(torch.argmin(torch.abs(self._step_sigmas - sigma)).item())
if step_index >= self._active_steps:
return 0.0
return self._decay**step_index
+89
View File
@@ -0,0 +1,89 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Integrate source-derived inversion states without ComfyUI dependencies."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import torch
from .noise_inversion import INVERSION_METHODS, InversionMethod
InversionVelocity = Callable[[torch.Tensor, torch.Tensor, int], torch.Tensor]
SpatialResize = Callable[[torch.Tensor, int, int], torch.Tensor]
@dataclass(slots=True)
class InversionSolverEvidence:
"""Count the actual denoiser evaluations performed by an integration stage."""
evaluations: int = 0
def integrate_inversion(
source: torch.Tensor,
sigmas: torch.Tensor,
evaluate: InversionVelocity,
*,
method: InversionMethod,
evidence: InversionSolverEvidence | None = None,
) -> torch.Tensor:
"""Advance a finite state over strictly increasing positive inversion sigmas."""
if method not in INVERSION_METHODS:
raise ValueError("Inversion method must be euler or heun.")
if sigmas.ndim != 1 or len(sigmas) < 2 or not bool(torch.isfinite(sigmas).all()):
raise ValueError("A finite one-dimensional inversion schedule is required.")
if not bool(torch.all(sigmas > 0)) or not bool(torch.all(sigmas[1:] > sigmas[:-1])):
raise ValueError("Inversion sigmas must be positive and increasing.")
if not source.is_floating_point() or not bool(torch.isfinite(source).all()):
raise ValueError("Inversion source must contain finite floating-point values.")
record = evidence if evidence is not None else InversionSolverEvidence()
state = source.clone()
def velocity(x: torch.Tensor, sigma: torch.Tensor, index: int) -> torch.Tensor:
"""Count every denoiser evaluation and reject corrupted predictions."""
record.evaluations += 1
value = evaluate(x, sigma, index)
if value.shape != x.shape or not bool(torch.isfinite(value).all()):
raise FloatingPointError("Invalid inversion velocity shape or values.")
return value
for index in range(len(sigmas) - 1):
current, following = sigmas[index], sigmas[index + 1]
delta = following - current
estimate = velocity(state, current, index)
if method == "heun":
corrected = velocity(state + delta * estimate, following, index)
estimate = (estimate + corrected) / 2
state = state + delta * estimate
if not bool(torch.isfinite(state).all()):
raise FloatingPointError(f"Non-finite inversion state at step {index}.")
return state
def lift_inversion_displacement(
full_source: torch.Tensor,
coarse_source: torch.Tensor,
coarse_endpoint: torch.Tensor,
*,
resize: SpatialResize,
) -> torch.Tensor:
"""Lift only the inferred change so existing full-size detail survives transfer."""
if coarse_source.shape != coarse_endpoint.shape:
raise ValueError("Coarse inversion source and endpoint shapes must match.")
if full_source.shape[:-2] != coarse_source.shape[:-2]:
raise ValueError(
"Inversion transfer must preserve batch and channel dimensions."
)
height, width = full_source.shape[-2:]
lifted = resize(coarse_endpoint - coarse_source, height, width)
if lifted.shape != full_source.shape:
raise ValueError("Inversion displacement resize produced an invalid shape.")
endpoint = full_source + lifted
if not bool(torch.isfinite(endpoint).all()):
raise FloatingPointError("Inversion transfer produced non-finite values.")
return endpoint
+82
View File
@@ -0,0 +1,82 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define model-independent checkpoint quantization profile contracts."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
class QuantizationFormat(StrEnum):
"""Identify one reusable ComfyUI tensor quantization format."""
FP8_E4M3 = "float8_e4m3fn"
FP8_E5M2 = "float8_e5m2"
NVFP4 = "nvfp4"
MXFP8 = "mxfp8"
@property
def label(self) -> str:
"""Return the concise format label used in diagnostics."""
return {
QuantizationFormat.FP8_E4M3: "FP8 E4M3",
QuantizationFormat.FP8_E5M2: "FP8 E5M2",
QuantizationFormat.NVFP4: "NVFP4",
QuantizationFormat.MXFP8: "MXFP8",
}[self]
@dataclass(frozen=True)
class QuantizationProfile:
"""Describe one workflow-facing, versioned per-tensor policy profile."""
profile_id: str
label: str
version: int
required_formats: frozenset[QuantizationFormat]
@property
def is_original(self) -> bool:
"""Return whether this profile loads the source checkpoint unchanged."""
return not self.required_formats
@dataclass(frozen=True)
class TensorDescriptor:
"""Describe a checkpoint tensor without coupling policy to PyTorch."""
name: str
shape: tuple[int, ...]
dtype_name: str
class ModelQuantizationRecipe(Protocol):
"""Assign model-specific per-tensor formats for named profiles."""
@property
def model_family(self) -> str:
"""Return the stable family identifier used in cache identity."""
@property
def version(self) -> int:
"""Return the recipe version used in cache invalidation."""
@property
def profiles(self) -> tuple[QuantizationProfile, ...]:
"""Return deterministic workflow profiles owned by this recipe."""
def profile_from_selection(self, selection: str) -> QuantizationProfile:
"""Parse a workflow selection into a recipe-owned profile."""
def policy_for(
self,
tensor: TensorDescriptor,
profile: QuantizationProfile,
) -> QuantizationFormat | None:
"""Return the tensor format or ``None`` to preserve source precision."""
@@ -0,0 +1,89 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Detect effective negative weights in Comfy-style prompt emphasis."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class _WeightedPromptSegment:
"""Retain one parsed prompt fragment and its effective scalar weight."""
text: str
weight: float
def contains_negative_prompt_weight(text: str) -> bool:
"""Return whether valid nested emphasis gives any prompt text a negative weight."""
if not isinstance(text, str):
raise TypeError("Negative prompt-weight detection requires text.")
escaped = text.replace(r"\)", "\0\1").replace(r"\(", "\0\2")
return any(
segment.text and segment.weight < 0.0
for segment in _weighted_segments(escaped, 1.0)
)
def _weighted_segments(
text: str,
current_weight: float,
) -> tuple[_WeightedPromptSegment, ...]:
"""Parse emphasis with the same nesting and final-colon rules as ComfyUI."""
parsed: list[_WeightedPromptSegment] = []
for item in _parenthesized_items(text):
weight = current_weight
if len(item) >= 2 and item[0] == "(" and item[-1] == ")":
inner = item[1:-1]
delimiter = inner.rfind(":")
weight *= 1.1
if delimiter > 0:
try:
weight = float(inner[delimiter + 1 :])
except ValueError:
pass
else:
inner = inner[:delimiter]
parsed.extend(_weighted_segments(inner, weight))
continue
parsed.append(
_WeightedPromptSegment(
item.replace("\0\1", ")").replace("\0\2", "("),
current_weight,
)
)
return tuple(parsed)
def _parenthesized_items(text: str) -> tuple[str, ...]:
"""Split top-level parenthesized regions while preserving malformed input."""
result: list[str] = []
current = ""
nesting = 0
for character in text:
if character == "(":
if nesting == 0:
if current:
result.append(current)
current = "("
else:
current += character
nesting += 1
elif character == ")":
nesting -= 1
if nesting == 0:
result.append(f"{current})")
current = ""
else:
current += character
else:
current += character
if current:
result.append(current)
return tuple(result)
+68
View File
@@ -0,0 +1,68 @@
# 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 source-preserving noise inversion configuration."""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Literal
InversionMethod = Literal["euler", "heun"]
INVERSION_METHODS: tuple[InversionMethod, ...] = ("euler", "heun")
@dataclass(frozen=True, slots=True)
class NoiseInversionOptions:
"""Configure reduced-resolution inversion and an optional full-size finish.
The transition is a fraction of the target inversion sigma, not the forward
denoise steps. A full-size inversion uses ``steps`` and needs no transfer.
"""
method: InversionMethod = "euler"
resolution_scale: float = 0.5
steps: int = 2
switch_fraction: float = 0.75
finishing_steps: int = 1
def __post_init__(self) -> None:
"""Reject invalid or internally incomplete inversion recipes."""
if self.method not in INVERSION_METHODS:
raise ValueError("Inversion method must be euler or heun.")
if (
not math.isfinite(self.resolution_scale)
or not 0 < self.resolution_scale <= 1
):
raise ValueError("Inversion resolution scale must be in (0, 1].")
if type(self.steps) is not int or not 1 <= self.steps <= 64:
raise ValueError("Inversion steps must be an integer between 1 and 64.")
if type(self.finishing_steps) is not int or not 0 <= self.finishing_steps <= 64:
raise ValueError("Inversion finishing steps must be between 0 and 64.")
if not math.isfinite(self.switch_fraction) or not 0 < self.switch_fraction <= 1:
raise ValueError("Inversion transition must be in (0, 1].")
if self.resolution_scale < 1 and self.finishing_steps > 0:
if self.switch_fraction == 1:
raise ValueError("A full-size finish requires a transition below 100%.")
@property
def coarse_target_fraction(self) -> float:
"""Reach the full target unless an enabled full-size stage follows transfer."""
return (
self.switch_fraction
if self.resolution_scale < 1 and self.finishing_steps
else 1.0
)
def coarse_shape(self, height: int, width: int) -> tuple[int, int]:
"""Preserve full dimensions or align reduced transformer grids to even sizes."""
if height < 1 or width < 1:
raise ValueError("Inversion source dimensions must be positive.")
if self.resolution_scale == 1:
return height, width
return (
max(2, round(height * self.resolution_scale / 2) * 2),
max(2, round(width * self.resolution_scale / 2) * 2),
)
+55
View File
@@ -0,0 +1,55 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Domain model for validated, duplicate-preserving file selections."""
from __future__ import annotations
import hashlib
from collections.abc import Callable, Sequence
from dataclasses import dataclass
@dataclass(frozen=True)
class OrderedFileSelection:
"""Represent non-empty workflow file positions without deduplication."""
paths: tuple[str, ...]
@classmethod
def require(
cls,
files: Sequence[str],
*,
node_name: str,
item_name: str,
) -> OrderedFileSelection:
"""Validate scalar-or-sequence workflow state into ordered paths."""
ordered = (files,) if isinstance(files, str) else tuple(files)
if not ordered:
raise ValueError(f"{node_name} requires at least one {item_name} file.")
if any(not isinstance(path, str) or not path for path in ordered):
raise TypeError(f"{node_name} {item_name} files must be non-empty strings.")
return cls(paths=ordered)
def fingerprint(
self,
file_fingerprint: Callable[[str], str],
*,
context: Sequence[str] = (),
) -> str:
"""Hash context, every path position, and each file's content digest."""
digest = hashlib.sha256()
for value in context:
encoded = value.encode("utf-8")
digest.update(len(encoded).to_bytes(8, "big"))
digest.update(encoded)
for path in self.paths:
encoded = path.encode("utf-8")
digest.update(len(encoded).to_bytes(8, "big"))
digest.update(encoded)
digest.update(file_fingerprint(path).encode("ascii"))
return digest.hexdigest()
@@ -0,0 +1,190 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own immutable processed regional Attention Coupling runtime plans."""
from __future__ import annotations
import math
from dataclasses import dataclass
from uuid import UUID
import torch
from .conditioning_schedule import ConditioningScheduleRange
from .regional_attention import (
require_non_negative_regional_attention_index,
validate_regional_attention_plan_authorities,
)
from .regional_lora_plan import RegionalLoraPlan
from .regional_mask_bank import RegionalMaskBank
@dataclass(frozen=True, slots=True)
class ProcessedRegionalAttentionEntry:
"""Retain one ordered model-ready conditioning entry and Comfy strength."""
entry_index: int
uuid: UUID
schedule: ConditioningScheduleRange
cross_attention: torch.Tensor
strength: float
cross_attention_value_multiplier: torch.Tensor | None = None
def __post_init__(self) -> None:
"""Validate entry order, model context, and finite scalar strength."""
require_non_negative_regional_attention_index(
self.entry_index,
name="entry_index",
)
if not isinstance(self.uuid, UUID):
raise TypeError("Processed conditioning entry UUID must be uuid.UUID.")
if not isinstance(self.schedule, ConditioningScheduleRange):
raise TypeError(
"Processed conditioning entry schedule has an invalid type."
)
if not isinstance(self.cross_attention, torch.Tensor):
raise TypeError("Processed cross_attention must be a torch.Tensor.")
if self.cross_attention.ndim != 3:
raise ValueError("Processed cross_attention must use BxSxD layout.")
if any(int(size) < 1 for size in self.cross_attention.shape):
raise ValueError("Processed cross_attention dimensions must be positive.")
if not self.cross_attention.is_floating_point():
raise TypeError("Processed cross_attention must be floating point.")
if not bool(torch.isfinite(self.cross_attention).all().item()):
raise ValueError("Processed cross_attention must contain finite values.")
if isinstance(self.strength, bool) or not isinstance(
self.strength,
int | float,
):
raise TypeError("Processed conditioning strength must be a real number.")
if not math.isfinite(float(self.strength)):
raise ValueError("Processed conditioning strength must be finite.")
object.__setattr__(self, "strength", float(self.strength))
multiplier = self.cross_attention_value_multiplier
if multiplier is None:
return
if (
not isinstance(multiplier, torch.Tensor)
or multiplier.shape != (*self.cross_attention.shape[:2], 1)
or not multiplier.is_floating_point()
or multiplier.device != self.cross_attention.device
or multiplier.dtype != self.cross_attention.dtype
or not bool(torch.isfinite(multiplier).all().item())
):
raise ValueError(
"Processed attention value multiplier must be a finite floating "
"BxSx1 tensor aligned with cross_attention."
)
@dataclass(frozen=True, slots=True)
class ProcessedRegionalAttentionContext:
"""Retain every ordered processed entry for one authored conditioning."""
conditioning_index: int
region_index: int | None
entries: tuple[ProcessedRegionalAttentionEntry, ...]
def __post_init__(self) -> None:
"""Validate global/regional ownership and model-ready tensor structure."""
if self.region_index is None:
if self.conditioning_index != 0:
raise ValueError(
"Processed base attention context must use conditioning index 0."
)
else:
require_non_negative_regional_attention_index(
self.region_index,
name="region_index",
)
if self.conditioning_index != self.region_index + 1:
raise ValueError(
"Processed regional conditioning_index must equal region_index + 1."
)
if not isinstance(self.entries, tuple) or not self.entries:
raise ValueError("Processed attention context requires ordered entries.")
if any(
not isinstance(entry, ProcessedRegionalAttentionEntry)
for entry in self.entries
):
raise TypeError("Processed attention context contains an invalid entry.")
if tuple(entry.entry_index for entry in self.entries) != tuple(
range(len(self.entries))
):
raise ValueError("Processed attention entries must use canonical order.")
@dataclass(frozen=True, slots=True)
class ProcessedRegionalAttentionBranch:
"""Retain one processed base context and ordered regional context bank."""
base_context: ProcessedRegionalAttentionContext
regional_contexts: tuple[ProcessedRegionalAttentionContext, ...]
def __post_init__(self) -> None:
"""Require one global base and canonical regional context order."""
if not isinstance(self.base_context, ProcessedRegionalAttentionContext):
raise TypeError("Processed attention base context has an invalid type.")
if self.base_context.region_index is not None:
raise ValueError("Processed attention base context must be global.")
if not isinstance(self.regional_contexts, tuple):
raise TypeError("Processed regional attention contexts must be a tuple.")
if any(
not isinstance(context, ProcessedRegionalAttentionContext)
for context in self.regional_contexts
):
raise TypeError(
"Processed regional attention branch contains an invalid context."
)
indices = tuple(context.region_index for context in self.regional_contexts)
if indices != tuple(range(len(self.regional_contexts))):
raise ValueError(
"Processed regional attention contexts must use canonical order."
)
@dataclass(frozen=True, slots=True)
class ProcessedRegionalAttentionPlan:
"""Retain processed branches and shared canonical regional authorities."""
positive: ProcessedRegionalAttentionBranch
negative: ProcessedRegionalAttentionBranch
mask_bank: RegionalMaskBank
lora_plan: RegionalLoraPlan
def __post_init__(self) -> None:
"""Validate plan owners and regional LoRA bounds."""
if not isinstance(
self.positive, ProcessedRegionalAttentionBranch
) or not isinstance(self.negative, ProcessedRegionalAttentionBranch):
raise TypeError(
"Processed regional attention plan contains an invalid branch."
)
validate_regional_attention_plan_authorities(
mask_bank=self.mask_bank,
lora_plan=self.lora_plan,
positive_region_count=len(self.positive.regional_contexts),
negative_region_count=len(self.negative.regional_contexts),
)
@property
def is_time_invariant(self) -> bool:
"""Report whether contexts and regional LoRA strengths remain fixed."""
branches = (self.positive, self.negative)
contexts = tuple(
context
for branch in branches
for context in (branch.base_context, *branch.regional_contexts)
)
return self.lora_plan.is_time_invariant and all(
entry.schedule.is_time_invariant
for context in contexts
for entry in context.entries
)
@@ -0,0 +1,32 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Parse authored prompt text into ordered batch entries."""
from __future__ import annotations
import re
DEFAULT_PROMPT_BATCH_SEPARATOR = "[SEP]"
_NAMED_DEFAULT_SEPARATOR_PATTERN = r"\[SEP(?:\|[^\r\n\]]+)?\]"
def split_prompt_batch(
text: str,
separator: str = DEFAULT_PROMPT_BATCH_SEPARATOR,
) -> tuple[str, ...]:
"""Split prompt text while discarding default-separator labels."""
if separator == "":
raise ValueError("separator must not be empty.")
pattern = rf"\s*(?:{_separator_pattern(separator)})\s*"
return tuple(re.split(pattern, text))
def _separator_pattern(separator: str) -> str:
"""Return labeled default grammar or an escaped custom separator pattern."""
if separator == DEFAULT_PROMPT_BATCH_SEPARATOR:
return _NAMED_DEFAULT_SEPARATOR_PATTERN
return re.escape(separator)
@@ -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
"""Prepare Prompt-Control prompt text for scheduling and encoding."""
from __future__ import annotations
import re
from dataclasses import dataclass
from .prompt_batch_parser import split_prompt_batch
PROMPT_TEXT_PATTERN = r"(?:^|>)([^<]+)(?=<|$)"
LORA_TAG_PATTERN = r"<[^>]*>"
@dataclass(frozen=True)
class PreparedPromptChunk:
"""Store one prompt chunk's cleaned text and scheduling tags."""
text: str
lora_tags: str
@dataclass(frozen=True)
class PreparedPromptSide:
"""Store ordered prompt chunks for one positive or negative prompt side."""
chunks: tuple[PreparedPromptChunk, ...]
@dataclass(frozen=True)
class PromptSegmentHookPlan:
"""Store the combined LoRA schedule for one aligned SEP position."""
lora_tags: str
def extract_prompt_text(text: str) -> str:
"""Return prompt text outside angle-bracket Prompt-Control tags."""
return _extract_all_matches(text, PROMPT_TEXT_PATTERN)
def extract_lora_tags(text: str) -> str:
"""Return angle-bracket Prompt-Control tags joined with newlines."""
return _extract_all_matches(text, LORA_TAG_PATTERN)
def prepare_prompt_side(text: str, separator: str) -> PreparedPromptSide:
"""Split a prompt side into ordered cleaned chunks with local LoRA tags."""
chunks = tuple(
PreparedPromptChunk(
text=extract_prompt_text(chunk),
lora_tags=extract_lora_tags(chunk),
)
for chunk in split_prompt_batch(text, separator)
)
return PreparedPromptSide(chunks=chunks)
def apply_encode_style(encode_style: str, prompt_text: str) -> str:
"""Prepend Prompt-Control encode style text exactly as provided."""
if not encode_style:
return prompt_text
return f"{encode_style}{prompt_text}"
def _extract_all_matches(text: str, pattern: str) -> str:
"""Match Comfy's RegexExtract All Matches behavior."""
matches = re.findall(pattern, text, re.IGNORECASE)
if not matches:
return ""
if isinstance(matches[0], tuple):
return "\n".join(match[0] for match in matches)
return "\n".join(matches)
@@ -0,0 +1,94 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Plan authored and global-fallback positions across two prompt sides."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeVar
SegmentValue = TypeVar("SegmentValue")
@dataclass(frozen=True)
class PromptSegmentSource:
"""Identify one effective segment's authored source position."""
source_index: int
authored: bool
def resolve(self, values: tuple[SegmentValue, ...]) -> SegmentValue:
"""Return the authored value selected for this effective position."""
return values[self.source_index]
@dataclass(frozen=True)
class PromptSideAlignment:
"""Describe effective positions for one authored prompt side."""
authored_count: int
sources: tuple[PromptSegmentSource, ...]
def materialize(
self,
values: tuple[SegmentValue, ...],
) -> tuple[SegmentValue, ...]:
"""Resolve effective values while rejecting a mismatched authored side."""
if len(values) != self.authored_count:
raise ValueError(
"prompt alignment expected "
f"{self.authored_count} authored segments but received {len(values)}."
)
return tuple(source.resolve(values) for source in self.sources)
@dataclass(frozen=True)
class PromptSegmentAlignment:
"""Store matched effective positions for positive and negative prompts."""
positive: PromptSideAlignment
negative: PromptSideAlignment
@property
def segment_count(self) -> int:
"""Return the shared number of effective prompt positions."""
return len(self.positive.sources)
def build_prompt_segment_alignment(
*,
positive_count: int,
negative_count: int,
) -> PromptSegmentAlignment:
"""Align prompt sides by filling missing positions from each global entry."""
if positive_count < 1 or negative_count < 1:
raise ValueError("each prompt side must contain at least one authored segment.")
segment_count = max(positive_count, negative_count)
return PromptSegmentAlignment(
positive=_build_side_alignment(positive_count, segment_count),
negative=_build_side_alignment(negative_count, segment_count),
)
def _build_side_alignment(
authored_count: int,
segment_count: int,
) -> PromptSideAlignment:
"""Return authored positions followed by global-entry fallback positions."""
return PromptSideAlignment(
authored_count=authored_count,
sources=tuple(
PromptSegmentSource(
source_index=index if index < authored_count else 0,
authored=index < authored_count,
)
for index in range(segment_count)
),
)
+310
View File
@@ -0,0 +1,310 @@
# 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 identities and records for quantized profile artifacts."""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import cast
from .model_quantization import QuantizationProfile
MANIFEST_SCHEMA_VERSION = 2
@dataclass(frozen=True)
class SourceCheckpointIdentity:
"""Identify an authoritative source checkpoint and its current file state."""
display_name: str
path: Path
size_bytes: int
modified_ns: int
sha256: str
@dataclass(frozen=True)
class QuantCacheIdentity:
"""Identify one profile and recipe derivative of a source checkpoint."""
source: SourceCheckpointIdentity
profile: QuantizationProfile
model_family: str
recipe_version: int
@property
def stable_key(self) -> str:
"""Return the collision-resistant cache key."""
identity = "\0".join(
(
self.source.sha256,
self.profile.profile_id,
str(self.profile.version),
self.model_family,
str(self.recipe_version),
)
)
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
@dataclass(frozen=True)
class QuantCacheManifest:
"""Describe one complete SimpleSyrup-managed cache artifact."""
source_model: str
source_path: str
source_sha256: str
source_size_bytes: int
source_modified_ns: int
profile_id: str
profile_label: str
profile_version: int
quantization_formats: tuple[str, ...]
model_family: str
recipe_version: int
artifact_file: str
artifact_size_bytes: int
created_at: str
last_used_at: str
schema_version: int = MANIFEST_SCHEMA_VERSION
managed_by: str = "SimpleSyrup"
@classmethod
def create(
cls,
identity: QuantCacheIdentity,
artifact_file: str,
artifact_size_bytes: int,
) -> QuantCacheManifest:
"""Create a current manifest for a completed artifact."""
now = datetime.now(UTC).isoformat()
return cls(
source_model=identity.source.display_name,
source_path=str(identity.source.path),
source_sha256=identity.source.sha256,
source_size_bytes=identity.source.size_bytes,
source_modified_ns=identity.source.modified_ns,
profile_id=identity.profile.profile_id,
profile_label=identity.profile.label,
profile_version=identity.profile.version,
quantization_formats=tuple(
sorted(item.value for item in identity.profile.required_formats)
),
model_family=identity.model_family,
recipe_version=identity.recipe_version,
artifact_file=artifact_file,
artifact_size_bytes=artifact_size_bytes,
created_at=now,
last_used_at=now,
)
def matches_current_source(
self,
source_model: str,
source_path: Path,
source_size_bytes: int,
source_modified_ns: int,
profile: QuantizationProfile,
model_family: str,
recipe_version: int,
) -> bool:
"""Return whether this artifact derives from the unchanged source file."""
return (
self.source_model == source_model
and Path(self.source_path) == source_path
and self.source_size_bytes == source_size_bytes
and self.source_modified_ns == source_modified_ns
and self.profile_id == profile.profile_id
and self.profile_version == profile.version
and self.model_family == model_family
and self.recipe_version == recipe_version
)
def matches_identity(self, identity: QuantCacheIdentity) -> bool:
"""Return whether this v2 manifest exactly describes an identity."""
return (
self.schema_version == MANIFEST_SCHEMA_VERSION
and self.source_sha256 == identity.source.sha256
and self.profile_id == identity.profile.profile_id
and self.profile_version == identity.profile.version
and self.model_family == identity.model_family
and self.recipe_version == identity.recipe_version
)
def touched(self) -> QuantCacheManifest:
"""Return a copy with a current explicit LRU timestamp."""
payload = self.to_payload()
payload["last_used_at"] = datetime.now(UTC).isoformat()
return QuantCacheManifest.from_payload(payload)
def to_payload(self) -> dict[str, object]:
"""Return the human-readable JSON representation."""
if self.schema_version == 1:
return {
"schema_version": 1,
"managed_by": self.managed_by,
"source_model": self.source_model,
"source_path": self.source_path,
"source_sha256": self.source_sha256,
"source_size_bytes": self.source_size_bytes,
"source_modified_ns": self.source_modified_ns,
"quantization_format": self.quantization_formats[0],
"model_family": self.model_family,
"recipe_version": self.recipe_version,
"artifact_file": self.artifact_file,
"artifact_size_bytes": self.artifact_size_bytes,
"created_at": self.created_at,
"last_used_at": self.last_used_at,
}
return {
"schema_version": self.schema_version,
"managed_by": self.managed_by,
"source_model": self.source_model,
"source_path": self.source_path,
"source_sha256": self.source_sha256,
"source_size_bytes": self.source_size_bytes,
"source_modified_ns": self.source_modified_ns,
"profile_id": self.profile_id,
"profile_label": self.profile_label,
"profile_version": self.profile_version,
"quantization_formats": list(self.quantization_formats),
"model_family": self.model_family,
"recipe_version": self.recipe_version,
"artifact_file": self.artifact_file,
"artifact_size_bytes": self.artifact_size_bytes,
"created_at": self.created_at,
"last_used_at": self.last_used_at,
}
@classmethod
def from_payload(cls, payload: object) -> QuantCacheManifest:
"""Validate managed manifests, retaining v1 only for cache cleanup."""
if not isinstance(payload, dict):
raise ValueError("Quant cache manifest must be a JSON object.")
if payload.get("schema_version") == 1:
return cls._from_legacy_payload(payload)
required_strings = (
"managed_by",
"source_model",
"source_path",
"source_sha256",
"profile_id",
"profile_label",
"model_family",
"artifact_file",
"created_at",
"last_used_at",
)
for key in required_strings:
if not isinstance(payload.get(key), str):
raise ValueError(
f"Quant cache manifest field '{key}' must be a string."
)
required_integers = (
"schema_version",
"source_size_bytes",
"source_modified_ns",
"profile_version",
"recipe_version",
"artifact_size_bytes",
)
for key in required_integers:
value = payload.get(key)
if not isinstance(value, int) or isinstance(value, bool):
raise ValueError(
f"Quant cache manifest field '{key}' must be an integer."
)
raw_formats = payload.get("quantization_formats")
if not isinstance(raw_formats, list) or not all(
isinstance(item, str) for item in raw_formats
):
raise ValueError(
"Quant cache manifest field 'quantization_formats' must be a "
"string list."
)
if payload["managed_by"] != "SimpleSyrup":
raise ValueError("Quant cache manifest is not managed by SimpleSyrup.")
if payload["schema_version"] != MANIFEST_SCHEMA_VERSION:
raise ValueError("Quant cache manifest schema version is unsupported.")
return cls(
schema_version=payload["schema_version"],
managed_by=payload["managed_by"],
source_model=payload["source_model"],
source_path=payload["source_path"],
source_sha256=payload["source_sha256"],
source_size_bytes=payload["source_size_bytes"],
source_modified_ns=payload["source_modified_ns"],
profile_id=payload["profile_id"],
profile_label=payload["profile_label"],
profile_version=payload["profile_version"],
quantization_formats=tuple(raw_formats),
model_family=payload["model_family"],
recipe_version=payload["recipe_version"],
artifact_file=payload["artifact_file"],
artifact_size_bytes=payload["artifact_size_bytes"],
created_at=payload["created_at"],
last_used_at=payload["last_used_at"],
)
@classmethod
def _from_legacy_payload(cls, payload: dict[object, object]) -> QuantCacheManifest:
"""Decode v1 solely so ordinary LRU and clearing can remove it."""
required_strings = (
"managed_by",
"source_model",
"source_path",
"source_sha256",
"quantization_format",
"model_family",
"artifact_file",
"created_at",
"last_used_at",
)
required_integers = (
"source_size_bytes",
"source_modified_ns",
"recipe_version",
"artifact_size_bytes",
)
if any(not isinstance(payload.get(key), str) for key in required_strings):
raise ValueError("Legacy quant cache manifest has invalid string fields.")
if any(
not isinstance(payload.get(key), int) or isinstance(payload.get(key), bool)
for key in required_integers
):
raise ValueError("Legacy quant cache manifest has invalid integer fields.")
if payload["managed_by"] != "SimpleSyrup":
raise ValueError("Quant cache manifest is not managed by SimpleSyrup.")
quantization_format = str(payload["quantization_format"])
return cls(
schema_version=1,
managed_by=str(payload["managed_by"]),
source_model=str(payload["source_model"]),
source_path=str(payload["source_path"]),
source_sha256=str(payload["source_sha256"]),
source_size_bytes=cast(int, payload["source_size_bytes"]),
source_modified_ns=cast(int, payload["source_modified_ns"]),
profile_id=f"legacy-v1-{quantization_format}",
profile_label=f"Legacy v1 {quantization_format}",
profile_version=1,
quantization_formats=(quantization_format,),
model_family=str(payload["model_family"]),
recipe_version=cast(int, payload["recipe_version"]),
artifact_file=str(payload["artifact_file"]),
artifact_size_bytes=cast(int, payload["artifact_size_bytes"]),
created_at=str(payload["created_at"]),
last_used_at=str(payload["last_used_at"]),
)
@@ -0,0 +1,147 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own immutable raw regional Attention Coupling authoring plans."""
from __future__ import annotations
from dataclasses import dataclass
from .conditioning_batch import ConditioningBatch
from .regional_attention import (
require_non_negative_regional_attention_index,
validate_regional_attention_plan_authorities,
)
from .regional_lora_plan import EMPTY_REGIONAL_LORA_PLAN, RegionalLoraPlan
from .regional_mask_bank import RegionalMaskBank
from .regional_prompting import build_regional_conditioning_plan
@dataclass(frozen=True, slots=True)
class RawRegionalAttentionContext:
"""Retain one original regional conditioning and its canonical indices."""
conditioning_index: int
region_index: int
conditioning: object
def __post_init__(self) -> None:
"""Require the established global-first positional relationship."""
require_non_negative_regional_attention_index(
self.region_index,
name="region_index",
)
if self.conditioning_index != self.region_index + 1:
raise ValueError(
"Regional attention conditioning_index must equal region_index + 1."
)
@dataclass(frozen=True, slots=True)
class RawRegionalAttentionBranch:
"""Retain one base conditioning and ordered regional conditionings."""
base_conditioning: object
regional_contexts: tuple[RawRegionalAttentionContext, ...]
def __post_init__(self) -> None:
"""Require immutable canonical regional order."""
if not isinstance(self.regional_contexts, tuple):
raise TypeError("Raw regional attention contexts must be a tuple.")
if any(
not isinstance(context, RawRegionalAttentionContext)
for context in self.regional_contexts
):
raise TypeError(
"Raw regional attention branch contains an invalid context."
)
indices = tuple(context.region_index for context in self.regional_contexts)
if indices != tuple(range(len(self.regional_contexts))):
raise ValueError(
"Raw regional attention contexts must use canonical order."
)
@dataclass(frozen=True, slots=True)
class RawRegionalAttentionPlan:
"""Retain both raw branches and shared canonical regional authorities."""
positive: RawRegionalAttentionBranch
negative: RawRegionalAttentionBranch
mask_bank: RegionalMaskBank
lora_plan: RegionalLoraPlan
def __post_init__(self) -> None:
"""Validate plan owners and regional LoRA bounds."""
if not isinstance(self.positive, RawRegionalAttentionBranch) or not isinstance(
self.negative, RawRegionalAttentionBranch
):
raise TypeError("Raw regional attention plan contains an invalid branch.")
validate_regional_attention_plan_authorities(
mask_bank=self.mask_bank,
lora_plan=self.lora_plan,
positive_region_count=len(self.positive.regional_contexts),
negative_region_count=len(self.negative.regional_contexts),
)
def build_raw_regional_attention_plan(
*,
positive: object,
negative: object,
mask_bank: RegionalMaskBank,
lora_plan: RegionalLoraPlan = EMPTY_REGIONAL_LORA_PLAN,
) -> RawRegionalAttentionPlan:
"""Build both branches through the authoritative global-first pairing policy."""
if not isinstance(mask_bank, RegionalMaskBank):
raise TypeError("Raw regional attention requires a RegionalMaskBank.")
return RawRegionalAttentionPlan(
positive=_build_raw_branch(
positive,
mask_bank=mask_bank,
input_name="positive",
),
negative=_build_raw_branch(
negative,
mask_bank=mask_bank,
input_name="negative",
),
mask_bank=mask_bank,
lora_plan=lora_plan,
)
def _build_raw_branch(
conditioning: object,
*,
mask_bank: RegionalMaskBank,
input_name: str,
) -> RawRegionalAttentionBranch:
"""Pair one raw conditioning branch without restating index policy."""
entries = (
conditioning.entries
if isinstance(conditioning, ConditioningBatch)
else (conditioning,)
)
pairing = build_regional_conditioning_plan(
region_count=mask_bank.region_count,
conditioning_count=len(entries),
input_name=input_name,
)
return RawRegionalAttentionBranch(
base_conditioning=entries[0],
regional_contexts=tuple(
RawRegionalAttentionContext(
conditioning_index=pair.conditioning_index,
region_index=pair.mask_index,
conditioning=entries[pair.conditioning_index],
)
for pair in pairing.pairs
),
)
@@ -0,0 +1,237 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define model-neutral regional activation geometry and batch alignment."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from .spatial_views import SpatialBatchLayout
class RegionalActivationLayout(StrEnum):
"""Classify the explicit spatial organization of one adapter activation."""
DIRECT_CONVOLUTION_1D = "direct_convolution_1d"
DIRECT_CONVOLUTION_2D = "direct_convolution_2d"
DIRECT_CONVOLUTION_3D = "direct_convolution_3d"
FLATTENED_SPATIAL_TOKENS = "flattened_spatial_tokens"
CONSUMER_SPATIALIZED = "consumer_spatialized"
BRANCH_TOKENS = "branch_tokens"
class RegionalTemporalOwnership(StrEnum):
"""Declare how a two-dimensional authored mask owns temporal activations."""
NONE = "none"
REPEAT_SPATIAL_MASK = "repeat_spatial_mask"
@dataclass(frozen=True, slots=True)
class RegionalActivationBatchAlignment:
"""Retain CFG, latent-batch, and optional view-major alignment evidence."""
latent_batch_size: int
chunk_count: int
spatial_layout: SpatialBatchLayout | None = None
def __post_init__(self) -> None:
"""Require positive counts and one layout over the complete base batch."""
_positive_integer(self.latent_batch_size, name="latent_batch_size")
_positive_integer(self.chunk_count, name="chunk_count")
if self.spatial_layout is not None:
if not isinstance(self.spatial_layout, SpatialBatchLayout):
raise TypeError(
"Regional activation spatial_layout must be a SpatialBatchLayout."
)
if self.spatial_layout.input_batch_size != self.base_batch_size:
raise ValueError(
"Regional activation spatial layout input batch must match "
"CFG chunks times latent batch size."
)
@property
def base_batch_size(self) -> int:
"""Return the model batch before spatial-view expansion."""
return self.latent_batch_size * self.chunk_count
@property
def invocation_batch_size(self) -> int:
"""Return the active model batch after optional view expansion."""
if self.spatial_layout is None:
return self.base_batch_size
return self.spatial_layout.expanded_batch_size
@dataclass(frozen=True, slots=True)
class RegionalActivationGeometry:
"""Describe one exact rank activation and its authored-mask correspondence."""
layout: RegionalActivationLayout
invocation_shape: tuple[int, ...]
feature_axis: int
spatial_height: int
spatial_width: int
batch_alignment: RegionalActivationBatchAlignment
temporal_axis: int | None = None
temporal_ownership: RegionalTemporalOwnership = RegionalTemporalOwnership.NONE
def __post_init__(self) -> None:
"""Reject ambiguous axes, batches, tokens, and temporal ownership."""
if not isinstance(self.layout, RegionalActivationLayout):
raise TypeError("Regional activation layout has an invalid type.")
if not isinstance(self.invocation_shape, tuple) or not self.invocation_shape:
raise ValueError("Regional activation shape must be a nonempty tuple.")
for dimension in self.invocation_shape:
_positive_integer(dimension, name="shape dimension")
if not isinstance(self.batch_alignment, RegionalActivationBatchAlignment):
raise TypeError("Regional activation requires batch alignment evidence.")
_positive_integer(self.spatial_height, name="spatial_height")
_positive_integer(self.spatial_width, name="spatial_width")
if self.invocation_shape[0] != self.batch_alignment.invocation_batch_size:
raise ValueError(
"Regional activation leading batch must match its alignment."
)
rank = len(self.invocation_shape)
_axis(self.feature_axis, rank=rank, name="feature_axis")
if self.feature_axis == 0:
raise ValueError(
"Regional activation feature axis cannot be the batch axis."
)
if not isinstance(self.temporal_ownership, RegionalTemporalOwnership):
raise TypeError(
"Regional activation temporal ownership has an invalid type."
)
self._validate_layout()
def _validate_layout(self) -> None:
"""Match the declared layout to its exact conventional tensor shape."""
batch = self.batch_alignment.invocation_batch_size
features = self.invocation_shape[self.feature_axis]
if self.layout is RegionalActivationLayout.DIRECT_CONVOLUTION_1D:
self._require_shape((batch, features, self.spatial_width), feature_axis=1)
if self.spatial_height != 1:
raise ValueError("Direct Conv1d regional geometry requires height one.")
self._require_no_temporal_axis()
return
if self.layout is RegionalActivationLayout.DIRECT_CONVOLUTION_2D:
self._require_shape(
(batch, features, self.spatial_height, self.spatial_width),
feature_axis=1,
)
self._require_no_temporal_axis()
return
if self.layout is RegionalActivationLayout.DIRECT_CONVOLUTION_3D:
if self.feature_axis != 1 or len(self.invocation_shape) != 5:
raise ValueError("Direct Conv3d regional geometry requires B/C/D/H/W.")
if self.temporal_axis != 2:
raise ValueError(
"Direct Conv3d regional geometry requires temporal axis 2."
)
if self.invocation_shape[3:] != (
self.spatial_height,
self.spatial_width,
):
raise ValueError("Direct Conv3d regional H/W must match its tensor.")
if (
self.temporal_ownership
is not RegionalTemporalOwnership.REPEAT_SPATIAL_MASK
):
raise ValueError(
"Direct Conv3d regional geometry requires explicit repeated "
"spatial-mask temporal ownership."
)
return
if self.layout in (
RegionalActivationLayout.FLATTENED_SPATIAL_TOKENS,
RegionalActivationLayout.CONSUMER_SPATIALIZED,
):
self._require_shape(
(
batch,
self.spatial_height * self.spatial_width,
features,
),
feature_axis=2,
)
self._require_no_temporal_axis()
return
if self.layout is RegionalActivationLayout.BRANCH_TOKENS:
self._require_shape(
(batch, self.spatial_width, features),
feature_axis=2,
)
if self.spatial_height != 1:
raise ValueError("Branch-token regional geometry requires height one.")
self._require_no_temporal_axis()
return
raise AssertionError(f"Unhandled regional activation layout: {self.layout}")
def _require_shape(
self,
expected: tuple[int, ...],
*,
feature_axis: int,
) -> None:
"""Require one conventional shape and feature-axis location."""
if self.feature_axis != feature_axis or self.invocation_shape != expected:
raise ValueError(
f"{self.layout.value} regional geometry expected shape {expected} "
f"with feature axis {feature_axis}; observed "
f"{self.invocation_shape} and axis {self.feature_axis}."
)
def _require_no_temporal_axis(self) -> None:
"""Reject temporal claims from non-temporal image activation layouts."""
if self.temporal_axis is not None:
raise ValueError(
"Non-temporal regional geometry cannot declare a temporal axis."
)
if self.temporal_ownership is not RegionalTemporalOwnership.NONE:
raise ValueError(
"Non-temporal regional geometry cannot claim temporal ownership."
)
@property
def temporal_size(self) -> int | None:
"""Return the explicit temporal size when this activation owns one."""
if self.temporal_axis is None:
return None
return self.invocation_shape[self.temporal_axis]
def broadcast_mask_shape(self, region_count: int) -> tuple[int, ...]:
"""Return the exact region-major multiplier shape for this activation."""
_positive_integer(region_count, name="region_count")
shape = list(self.invocation_shape)
shape[self.feature_axis] = 1
return (region_count, *shape)
def _positive_integer(value: object, *, name: str) -> None:
"""Require one strictly positive non-boolean integer."""
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"Regional activation {name} must be an integer.")
if value < 1:
raise ValueError(f"Regional activation {name} must be positive.")
def _axis(value: object, *, rank: int, name: str) -> None:
"""Require one non-negative axis inside the invocation rank."""
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"Regional activation {name} must be an integer.")
if not 0 <= value < rank:
raise ValueError(f"Regional activation {name} is outside the tensor rank.")
+69
View File
@@ -0,0 +1,69 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own shared regional Attention Coupling contracts and validation."""
from __future__ import annotations
from enum import StrEnum
from .regional_lora_plan import RegionalLoraPlan
from .regional_mask_bank import RegionalMaskBank
class RegionalAttentionBranch(StrEnum):
"""Name the processed conditioning bank selected for one Comfy chunk."""
POSITIVE = "positive"
NEGATIVE = "negative"
def require_non_negative_regional_attention_index(
value: object,
*,
name: str,
) -> None:
"""Require one non-negative integer regional-attention index."""
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"Regional attention {name} must be an integer.")
if value < 0:
raise ValueError(f"Regional attention {name} must be non-negative.")
def validate_regional_attention_plan_authorities(
*,
mask_bank: object,
lora_plan: object,
positive_region_count: int,
negative_region_count: int,
) -> None:
"""Validate shared mask, LoRA, and branch-count plan authorities."""
if not isinstance(mask_bank, RegionalMaskBank):
raise TypeError("Regional attention plan requires a RegionalMaskBank.")
if not isinstance(lora_plan, RegionalLoraPlan):
raise TypeError("Regional attention plan requires a RegionalLoraPlan.")
for branch_name, region_count in (
("positive", positive_region_count),
("negative", negative_region_count),
):
require_non_negative_regional_attention_index(
region_count,
name=f"{branch_name} region count",
)
if region_count > mask_bank.region_count:
raise ValueError(
f"Regional attention {branch_name} branch exceeds the mask bank."
)
out_of_bounds = tuple(
adapter
for adapter in lora_plan.adapters
if adapter.region_index >= mask_bank.region_count
)
if out_of_bounds:
indices = ", ".join(str(adapter.region_index) for adapter in out_of_bounds)
raise ValueError(
"Regional attention LoRA region indices exceed the mask bank: " + indices
)
@@ -0,0 +1,227 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own immutable chunk-major regional attention batch alignment values."""
from __future__ import annotations
import math
from dataclasses import dataclass
import torch
from .regional_attention import RegionalAttentionBranch
@dataclass(frozen=True, slots=True)
class RegionalAttentionChunkBatch:
"""Describe one Comfy chunk's contiguous slice of the model batch."""
chunk_index: int
branch: RegionalAttentionBranch
batch_start: int
batch_stop: int
def __post_init__(self) -> None:
"""Validate one positive non-empty contiguous batch slice."""
for name, value in (
("chunk_index", self.chunk_index),
("batch_start", self.batch_start),
("batch_stop", self.batch_stop),
):
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"Regional attention {name} must be an integer.")
if self.chunk_index < 0 or self.batch_start < 0:
raise ValueError("Regional attention chunk indices must be non-negative.")
if self.batch_stop <= self.batch_start:
raise ValueError("Regional attention chunk batch slice must be non-empty.")
if not isinstance(self.branch, RegionalAttentionBranch):
raise TypeError("Regional attention chunk branch has an invalid type.")
@dataclass(frozen=True, slots=True)
class BatchedRegionalAttentionEntry:
"""Retain one aligned regional entry and its per-sample Comfy strengths."""
entry_index: int
context: torch.Tensor
strengths: tuple[float, ...]
cross_attention_value_multiplier: torch.Tensor | None = None
def __post_init__(self) -> None:
"""Validate entry order, aligned context, and finite sample strengths."""
if isinstance(self.entry_index, bool) or not isinstance(self.entry_index, int):
raise TypeError("Regional attention entry_index must be an integer.")
if self.entry_index < 0:
raise ValueError("Regional attention entry_index must be non-negative.")
_validate_aligned_context(self.context, name="entry")
if not isinstance(self.strengths, tuple):
raise TypeError("Regional attention entry strengths must be a tuple.")
if len(self.strengths) != int(self.context.shape[0]):
raise ValueError(
"Regional attention entry strength count must match its batch."
)
for strength in self.strengths:
if isinstance(strength, bool) or not isinstance(strength, int | float):
raise TypeError(
"Regional attention entry strength must be a real number."
)
if not math.isfinite(float(strength)):
raise ValueError("Regional attention entry strength must be finite.")
_validate_value_multiplier(
self.cross_attention_value_multiplier,
self.context,
name="entry",
)
@dataclass(frozen=True, slots=True)
class BatchedRegionalAttentionRegion:
"""Retain every aligned active conditioning entry for one region."""
region_index: int
entries: tuple[BatchedRegionalAttentionEntry, ...]
def __post_init__(self) -> None:
"""Require a non-empty canonical entry bank for one region."""
if isinstance(self.region_index, bool) or not isinstance(
self.region_index, int
):
raise TypeError("Regional attention region_index must be an integer.")
if self.region_index < 0:
raise ValueError("Regional attention region_index must be non-negative.")
if not isinstance(self.entries, tuple) or not self.entries:
raise ValueError("Regional attention region requires active entries.")
if any(
not isinstance(entry, BatchedRegionalAttentionEntry)
for entry in self.entries
):
raise TypeError("Regional attention region contains an invalid entry.")
if tuple(entry.entry_index for entry in self.entries) != tuple(
range(len(self.entries))
):
raise ValueError("Regional attention region entries must be canonical.")
@dataclass(frozen=True, slots=True)
class BatchedRegionalAttentionContexts:
"""Retain chunk-major base and per-region active-entry model contexts."""
latent_batch_size: int
chunks: tuple[RegionalAttentionChunkBatch, ...]
base_context: torch.Tensor
regions: tuple[BatchedRegionalAttentionRegion, ...]
base_value_multiplier: torch.Tensor | None = None
def __post_init__(self) -> None:
"""Validate complete chunk and tensor alignment."""
if isinstance(self.latent_batch_size, bool) or not isinstance(
self.latent_batch_size, int
):
raise TypeError("Regional attention latent_batch_size must be an integer.")
if self.latent_batch_size < 1:
raise ValueError("Regional attention latent_batch_size must be positive.")
if not isinstance(self.chunks, tuple) or not self.chunks:
raise ValueError("Regional attention batch requires at least one chunk.")
if any(
not isinstance(chunk, RegionalAttentionChunkBatch) for chunk in self.chunks
):
raise TypeError("Regional attention batch contains an invalid chunk.")
expected_start = 0
for chunk_index, chunk in enumerate(self.chunks):
if chunk.chunk_index != chunk_index or chunk.batch_start != expected_start:
raise ValueError(
"Regional attention chunks must be contiguous and ordered."
)
if chunk.batch_stop - chunk.batch_start != self.latent_batch_size:
raise ValueError(
"Regional attention chunk size must match latent batch."
)
expected_start = chunk.batch_stop
_validate_aligned_context(
self.base_context,
expected_batch=expected_start,
name="base",
)
_validate_value_multiplier(
self.base_value_multiplier,
self.base_context,
name="base",
)
if not isinstance(self.regions, tuple):
raise TypeError("Regional attention regions must be a tuple.")
if tuple(region.region_index for region in self.regions) != tuple(
range(len(self.regions))
):
raise ValueError("Regional attention regions must use canonical order.")
for region in self.regions:
for entry in region.entries:
_validate_aligned_context(
entry.context,
expected_batch=expected_start,
name=f"region {region.region_index} entry {entry.entry_index}",
)
if entry.context.shape[1:] != self.base_context.shape[1:]:
raise ValueError(
"Regional attention context sequence shapes must match."
)
if (
entry.context.device != self.base_context.device
or entry.context.dtype != self.base_context.dtype
):
raise ValueError(
"Regional attention context device and dtype must match."
)
def _validate_aligned_context(
context: object,
*,
name: str,
expected_batch: int | None = None,
) -> None:
"""Validate one finite floating BxSxD context tensor."""
if not isinstance(context, torch.Tensor):
raise TypeError(f"Regional attention {name} context must be a tensor.")
if context.ndim != 3 or (
expected_batch is not None and int(context.shape[0]) != expected_batch
):
raise ValueError(
f"Regional attention {name} context has an invalid aligned batch."
)
if not context.is_floating_point() or not bool(
torch.isfinite(context).all().item()
):
raise ValueError(
f"Regional attention {name} context must contain finite floating values."
)
def _validate_value_multiplier(
multiplier: object,
context: torch.Tensor,
*,
name: str,
) -> None:
"""Validate one optional value multiplier against its aligned context."""
if multiplier is None:
return
if (
not isinstance(multiplier, torch.Tensor)
or multiplier.shape != (*context.shape[:2], 1)
or not multiplier.is_floating_point()
or multiplier.device != context.device
or multiplier.dtype != context.dtype
or not bool(torch.isfinite(multiplier).all().item())
):
raise ValueError(
f"Regional attention {name} value multiplier must be a finite "
"floating BxSx1 tensor aligned with its context."
)
@@ -0,0 +1,15 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own the immutable spatial execution vocabulary for regional attention."""
from enum import StrEnum
class RegionalAttentionExecutionMode(StrEnum):
"""Identify how one prepared regional model traverses the latent canvas."""
FULL = "full-context"
TILED = "tiled"
CONTEXTUAL = "Contextual"
@@ -0,0 +1,220 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Select exact active processed Attention Coupling entries for one sigma."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from .conditioning_schedule_selection import (
CONDITIONING_SCHEDULE_SELECTION_POLICY,
ConditioningScheduleSelectionPolicy,
normalize_conditioning_sigma,
)
from .processed_regional_attention import (
ProcessedRegionalAttentionBranch,
ProcessedRegionalAttentionEntry,
ProcessedRegionalAttentionPlan,
)
from .regional_attention import (
RegionalAttentionBranch,
require_non_negative_regional_attention_index,
)
@dataclass(frozen=True, slots=True)
class ActiveProcessedRegionalAttentionChunk:
"""Bind one Comfy chunk UUID to its active base and regional entries."""
chunk_index: int
branch: RegionalAttentionBranch
base_entry: ProcessedRegionalAttentionEntry
regional_entries: tuple[
tuple[ProcessedRegionalAttentionEntry, ...] | None,
...,
]
def __post_init__(self) -> None:
"""Validate canonical immutable active selection state."""
require_non_negative_regional_attention_index(
self.chunk_index,
name="chunk_index",
)
if not isinstance(self.branch, RegionalAttentionBranch):
raise TypeError("Regional attention chunk branch has an invalid type.")
if not isinstance(self.base_entry, ProcessedRegionalAttentionEntry):
raise TypeError("Regional attention chunk base entry has an invalid type.")
if not isinstance(self.regional_entries, tuple):
raise TypeError("Regional attention chunk regions must be a tuple.")
for entries in self.regional_entries:
if entries is not None and (
not isinstance(entries, tuple)
or any(
not isinstance(entry, ProcessedRegionalAttentionEntry)
for entry in entries
)
):
raise TypeError(
"Regional attention chunk contains invalid active entries."
)
class RegionalAttentionSelectionService:
"""Match Comfy chunk UUIDs to active base and regional entry banks."""
def __init__(
self,
schedule_policy: ConditioningScheduleSelectionPolicy | None = None,
) -> None:
"""Retain the focused schedule policy collaborator."""
self._schedule_policy = schedule_policy or ConditioningScheduleSelectionPolicy()
def select_chunks(
self,
plan: ProcessedRegionalAttentionPlan,
*,
cond_or_uncond: object,
conditioning_uuids: object,
sigma: float,
) -> tuple[ActiveProcessedRegionalAttentionChunk, ...]:
"""Select exact active entries in Comfy's supplied UUID order."""
if not isinstance(plan, ProcessedRegionalAttentionPlan):
raise TypeError("Regional attention selection requires a processed plan.")
selectors = _selectors(cond_or_uncond)
identities = _conditioning_uuids(conditioning_uuids)
if len(selectors) != len(identities):
raise ValueError(
"Regional attention selectors and UUIDs must have equal lengths."
)
current_sigma = normalize_conditioning_sigma(sigma)
return tuple(
self._select_chunk(
plan,
chunk_index=chunk_index,
selector=selector,
conditioning_uuid=conditioning_uuid,
sigma=current_sigma,
)
for chunk_index, (selector, conditioning_uuid) in enumerate(
zip(selectors, identities, strict=True)
)
)
def _select_chunk(
self,
plan: ProcessedRegionalAttentionPlan,
*,
chunk_index: int,
selector: object,
conditioning_uuid: UUID,
sigma: float,
) -> ActiveProcessedRegionalAttentionChunk:
"""Resolve one branch UUID and all regional schedules atomically."""
branch, contexts = _branch(plan, selector=selector, chunk_index=chunk_index)
matching_base = tuple(
entry
for entry in contexts.base_context.entries
if entry.uuid is conditioning_uuid
)
if len(matching_base) != 1:
raise ValueError(
f"Regional attention {branch.value} chunk {chunk_index} UUID "
"does not identify exactly one processed base entry."
)
base_entry = matching_base[0]
if not self._schedule_policy.is_active(base_entry.schedule, sigma=sigma):
raise ValueError(
f"Regional attention {branch.value} chunk {chunk_index} UUID is "
f"inactive at sigma {sigma}."
)
return ActiveProcessedRegionalAttentionChunk(
chunk_index=chunk_index,
branch=branch,
base_entry=base_entry,
regional_entries=tuple(
self._regional_entries(contexts, region_index, sigma=sigma)
for region_index in range(plan.mask_bank.region_count)
),
)
def _regional_entries(
self,
branch: ProcessedRegionalAttentionBranch,
region_index: int,
*,
sigma: float,
) -> tuple[ProcessedRegionalAttentionEntry, ...] | None:
"""Distinguish absent regions from authored regions with no active entry."""
if region_index >= len(branch.regional_contexts):
return None
return self._active_entries(
branch.regional_contexts[region_index].entries,
sigma=sigma,
)
def _active_entries(
self,
entries: tuple[ProcessedRegionalAttentionEntry, ...],
*,
sigma: float,
) -> tuple[ProcessedRegionalAttentionEntry, ...]:
"""Retain every active entry in authored order at one finite sigma."""
return tuple(
entry
for entry in entries
if self._schedule_policy.is_active(entry.schedule, sigma=sigma)
)
def _branch(
plan: ProcessedRegionalAttentionPlan,
*,
selector: object,
chunk_index: int,
) -> tuple[RegionalAttentionBranch, ProcessedRegionalAttentionBranch]:
"""Narrow one Comfy branch selector without accepting booleans."""
if isinstance(selector, bool) or not isinstance(selector, int):
raise TypeError(
f"cond_or_uncond selector {chunk_index} must be integer 0 or 1."
)
if selector == 0:
return RegionalAttentionBranch.POSITIVE, plan.positive
if selector == 1:
return RegionalAttentionBranch.NEGATIVE, plan.negative
raise ValueError(
f"cond_or_uncond selector {chunk_index} must be 0 or 1; observed {selector}."
)
def _selectors(value: object) -> tuple[object, ...]:
"""Require Comfy's ordered selector container."""
if not isinstance(value, list | tuple):
raise TypeError("cond_or_uncond must be a list or tuple of chunk selectors.")
return tuple(value)
def _conditioning_uuids(value: object) -> tuple[UUID, ...]:
"""Require exact Comfy UUID objects for every supplied chunk."""
if not isinstance(value, list | tuple):
raise TypeError("Conditioning UUIDs must be a list or tuple.")
identities = tuple(value)
if any(not isinstance(identity, UUID) for identity in identities):
raise TypeError("Every conditioning identity must be a Comfy UUID.")
return identities
REGIONAL_ATTENTION_SELECTION_SERVICE = RegionalAttentionSelectionService(
CONDITIONING_SCHEDULE_SELECTION_POLICY
)
@@ -0,0 +1,252 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Normalize regional attention weights and blend ordered branch outputs."""
from __future__ import annotations
import math
from dataclasses import dataclass
import torch
@dataclass(frozen=True, slots=True)
class RegionalAttentionWeights:
"""Hold raw base, region, and denominator weights on one query grid."""
base: torch.Tensor
regions: torch.Tensor
denominator: torch.Tensor
def __post_init__(self) -> None:
"""Require consistent finite non-negative weighting tensors."""
for name, tensor in (
("Base", self.base),
("Region", self.regions),
("Denominator", self.denominator),
):
if not isinstance(tensor, torch.Tensor):
raise TypeError(f"{name} attention weights must be a torch.Tensor.")
if not tensor.is_floating_point():
raise TypeError(
f"{name} attention weights must use a floating-point dtype."
)
if not bool(torch.isfinite(tensor).all()):
raise ValueError(f"{name} attention weights must be finite.")
if not bool((tensor >= 0.0).all()):
raise ValueError(f"{name} attention weights must be non-negative.")
if self.base.ndim < 1:
raise ValueError("Base attention weights require a query-grid dimension.")
if self.regions.ndim != self.base.ndim + 1:
raise ValueError(
"Region attention weights require a leading region dimension."
)
if int(self.regions.shape[0]) < 1:
raise ValueError("Region attention weights require at least one region.")
if tuple(self.regions.shape[1:]) != tuple(self.base.shape):
raise ValueError("Region attention weights must match the base query grid.")
if self.denominator.shape != self.base.shape:
raise ValueError("Attention denominator must match the base query grid.")
if not (
self.base.dtype == self.regions.dtype == self.denominator.dtype
and self.base.device == self.regions.device == self.denominator.device
):
raise ValueError(
"Base, region, and denominator weights must share dtype and device."
)
if not bool((self.denominator > 0.0).all()):
raise ValueError("Attention denominator must be strictly positive.")
@property
def normalized_base(self) -> torch.Tensor:
"""Return normalized base-complement weights."""
return self.base / self.denominator
@property
def normalized_regions(self) -> torch.Tensor:
"""Return normalized ordered regional weights."""
return self.regions / self.denominator.unsqueeze(0)
class RegionalAttentionWeightingPolicy:
"""Own Comfy-compatible base complement and normalized branch composition."""
def weights(
self,
masks: torch.Tensor,
*,
region_strengths: tuple[float, ...],
epsilon: float = 1e-6,
) -> RegionalAttentionWeights:
"""Return raw weighting terms for ordered region-first query masks."""
self._validate_mask_structure(masks)
strengths = self._validate_strengths(
region_strengths,
region_count=int(masks.shape[0]),
)
if (
isinstance(epsilon, bool)
or not isinstance(epsilon, int | float)
or not math.isfinite(float(epsilon))
or epsilon <= 0.0
):
raise ValueError("Regional attention epsilon must be finite and positive.")
self._validate_mask_values(masks)
strength_shape = (len(strengths),) + (1,) * (masks.ndim - 1)
strength_tensor = masks.new_tensor(strengths).reshape(strength_shape)
region_weights = masks.clamp(0.0, 1.0) * strength_tensor
region_sum = region_weights.sum(dim=0)
base_weight = torch.relu(1.0 - region_sum)
denominator = (base_weight + region_sum).clamp_min(float(epsilon))
return RegionalAttentionWeights(
base=base_weight,
regions=region_weights,
denominator=denominator,
)
def blend(
self,
*,
weights: RegionalAttentionWeights,
base_output: torch.Tensor,
regional_outputs: torch.Tensor,
) -> torch.Tensor:
"""Blend one base and ordered regional outputs over their query grid."""
self._validate_outputs(
weights=weights,
base_output=base_output,
regional_outputs=regional_outputs,
)
feature_dimensions = base_output.ndim - weights.base.ndim
feature_shape = (1,) * feature_dimensions
base_weight = weights.base.reshape((*weights.base.shape, *feature_shape)).to(
dtype=base_output.dtype
)
region_weights = weights.regions.reshape(
(*weights.regions.shape, *feature_shape)
).to(dtype=base_output.dtype)
denominator = weights.denominator.reshape(
(*weights.denominator.shape, *feature_shape)
).to(dtype=base_output.dtype)
numerator = base_weight * base_output + (region_weights * regional_outputs).sum(
dim=0
)
blended = numerator / denominator
if not bool(torch.isfinite(blended).all()):
raise ValueError(
"Blended regional attention output contains non-finite values."
)
return blended
@staticmethod
def _validate_mask_structure(masks: torch.Tensor) -> None:
"""Validate mask type and shape without inspecting device values."""
if not isinstance(masks, torch.Tensor):
raise TypeError("Regional attention masks must be a torch.Tensor.")
if masks.ndim < 2:
raise ValueError(
"Regional attention masks require region and query-grid dimensions."
)
if int(masks.shape[0]) < 1 or any(int(size) < 1 for size in masks.shape[1:]):
raise ValueError(
"Regional attention masks require non-empty region and query grids."
)
if not masks.is_floating_point():
raise TypeError("Regional attention masks must use a floating-point dtype.")
@staticmethod
def _validate_mask_values(masks: torch.Tensor) -> None:
"""Reject non-finite mask values before constructing strength tensors."""
if not bool(torch.isfinite(masks).all()):
raise ValueError("Regional attention masks must contain finite values.")
@staticmethod
def _validate_strengths(
strengths: tuple[float, ...],
*,
region_count: int,
) -> tuple[float, ...]:
"""Validate ordered immutable regional strengths before tensor creation."""
if not isinstance(strengths, tuple):
raise TypeError("Regional attention strengths must be an immutable tuple.")
if len(strengths) != region_count:
raise ValueError(
"Regional attention strength count must match the mask region count."
)
normalized: list[float] = []
for index, strength in enumerate(strengths):
if isinstance(strength, bool) or not isinstance(strength, int | float):
raise TypeError(
f"Regional attention strength {index} must be a real number."
)
value = float(strength)
if not math.isfinite(value) or value < 0.0:
raise ValueError(
f"Regional attention strength {index} must be finite and "
"non-negative."
)
normalized.append(value)
return tuple(normalized)
@staticmethod
def _validate_outputs(
*,
weights: RegionalAttentionWeights,
base_output: torch.Tensor,
regional_outputs: torch.Tensor,
) -> None:
"""Validate branch output ordering, grids, features, and tensor state."""
if not isinstance(weights, RegionalAttentionWeights):
raise TypeError("Regional blend weights must be RegionalAttentionWeights.")
if not isinstance(base_output, torch.Tensor) or not isinstance(
regional_outputs, torch.Tensor
):
raise TypeError(
"Regional attention branch outputs must be torch.Tensor values."
)
if (
not base_output.is_floating_point()
or not regional_outputs.is_floating_point()
):
raise TypeError(
"Regional attention branch outputs must use floating-point dtypes."
)
if base_output.dtype != regional_outputs.dtype:
raise ValueError("Regional attention branch output dtypes must match.")
if base_output.device != regional_outputs.device:
raise ValueError("Regional attention branch output devices must match.")
expected_regional_shape = (int(weights.regions.shape[0]), *base_output.shape)
if regional_outputs.shape != expected_regional_shape:
raise ValueError(
"Regional outputs must contain one ordered branch per region with "
"the complete base output shape."
)
query_dimensions = weights.base.ndim
if base_output.ndim < query_dimensions or tuple(
base_output.shape[:query_dimensions]
) != tuple(weights.base.shape):
raise ValueError(
"Regional attention outputs must begin with the weighting query grid."
)
if weights.base.device != base_output.device:
raise ValueError(
"Regional weights and branch outputs must share one device."
)
if not bool(torch.isfinite(base_output).all()) or not bool(
torch.isfinite(regional_outputs).all()
):
raise ValueError(
"Regional attention branch outputs must contain finite values."
)
@@ -0,0 +1,105 @@
# 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-equivalent within-region conditioning-output combination."""
from __future__ import annotations
import math
import torch
class RegionalConditioningOutputCombiner:
"""Combine ordered active-entry outputs with native Comfy strength semantics."""
def combine(
self,
outputs: tuple[torch.Tensor, ...],
*,
strengths: tuple[tuple[float, ...], ...],
) -> torch.Tensor:
"""Return the ordered strength-weighted output normalized like Comfy."""
if not isinstance(outputs, tuple) or not outputs:
raise ValueError("Regional conditioning combination requires outputs.")
if not isinstance(strengths, tuple) or len(strengths) != len(outputs):
raise ValueError(
"Regional conditioning strengths must align with ordered outputs."
)
authority = outputs[0]
if not isinstance(authority, torch.Tensor) or authority.ndim < 1:
raise TypeError("Regional conditioning output must be a tensor batch.")
if len(outputs) == 1:
entry_strengths = strengths[0]
self._validate_entry(
authority,
entry_strengths,
authority=authority,
entry_index=0,
)
if all(strength == 1.0 for strength in entry_strengths):
return authority
weighted = torch.zeros_like(authority)
counts = torch.ones_like(authority) * 1e-37
weight_shape = (int(authority.shape[0]),) + (1,) * (authority.ndim - 1)
active_rows = torch.zeros(
weight_shape,
dtype=torch.bool,
device=authority.device,
)
for entry_index, (output, entry_strengths) in enumerate(
zip(outputs, strengths, strict=True)
):
self._validate_entry(
output,
entry_strengths,
authority=authority,
entry_index=entry_index,
)
weights = authority.new_tensor(entry_strengths).reshape(weight_shape)
weighted += output * weights
counts += weights
active_rows |= weights.ne(0)
denominator = torch.where(active_rows, counts, torch.ones_like(counts))
return weighted / denominator
@staticmethod
def _validate_entry(
output: object,
strengths: object,
*,
authority: torch.Tensor,
entry_index: int,
) -> None:
"""Require exact output structure and one finite strength per sample."""
if not isinstance(output, torch.Tensor):
raise TypeError(
f"Regional conditioning output {entry_index} must be a tensor."
)
if (
output.shape != authority.shape
or output.device != authority.device
or output.dtype != authority.dtype
):
raise ValueError(
f"Regional conditioning output {entry_index} must match the first "
"output shape, device, and dtype."
)
if not isinstance(strengths, tuple) or len(strengths) != int(
authority.shape[0]
):
raise ValueError(
f"Regional conditioning output {entry_index} strengths must match "
"the output batch."
)
for strength in strengths:
if isinstance(strength, bool) or not isinstance(strength, int | float):
raise TypeError("Regional conditioning strengths must be real numbers.")
if not math.isfinite(float(strength)):
raise ValueError("Regional conditioning strengths must be finite.")
REGIONAL_CONDITIONING_OUTPUT_COMBINER = RegionalConditioningOutputCombiner()
+145
View File
@@ -0,0 +1,145 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define immutable regional feature requests, capabilities, and admissions."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from .regional_model_capabilities import RegionalModelCapabilities
class RegionalFeature(StrEnum):
"""Identify one independently admitted regional sampling feature."""
FULL_CONTEXT_MASKED_CONDITIONING = "full_context_masked_conditioning"
ATTENTION_COUPLING = "attention_coupling"
SPATIAL_MODEL_PATCH = "spatial_model_patch"
CONTROL = "control"
GLIGEN = "gligen"
REFERENCE_LATENTS = "reference_latents"
@dataclass(frozen=True, slots=True)
class RegionalFeatureRequest:
"""Describe the complete immutable regional feature intent for one sample."""
features: frozenset[RegionalFeature] = frozenset()
def __post_init__(self) -> None:
"""Require an immutable set containing only typed regional features."""
_validate_features(self.features, value_name="Regional feature request")
def with_feature(self, feature: RegionalFeature) -> RegionalFeatureRequest:
"""Return a new request containing one additional typed feature."""
if not isinstance(feature, RegionalFeature):
raise TypeError("Requested regional feature must be a RegionalFeature.")
return RegionalFeatureRequest(self.features | {feature})
@dataclass(frozen=True, slots=True)
class RegionalSamplerCapabilities:
"""Describe the complete regional feature set implemented by one sampler."""
features: frozenset[RegionalFeature]
def __post_init__(self) -> None:
"""Require an immutable set containing only typed regional features."""
_validate_features(self.features, value_name="Regional sampler capabilities")
@dataclass(frozen=True, slots=True)
class RegionalCapabilityAdmission:
"""Record one complete successful request admission for downstream use."""
request: RegionalFeatureRequest
admitted_features: frozenset[RegionalFeature]
model_capabilities: RegionalModelCapabilities | None
def __post_init__(self) -> None:
"""Reject partial, untyped, or model-incomplete admission state."""
if not isinstance(self.request, RegionalFeatureRequest):
raise TypeError(
"Regional admission request must be RegionalFeatureRequest."
)
_validate_features(
self.admitted_features,
value_name="Admitted regional features",
)
if self.admitted_features != self.request.features:
raise ValueError("Regional capability admission cannot be partial.")
if self.model_capabilities is not None and not isinstance(
self.model_capabilities,
RegionalModelCapabilities,
):
raise TypeError(
"Regional admission model capabilities must be "
"RegionalModelCapabilities."
)
if self.admitted_features & MODEL_DEPENDENT_REGIONAL_FEATURES:
if self.model_capabilities is None:
raise ValueError(
"Model-dependent regional features require model capabilities."
)
def supports(self, feature: RegionalFeature) -> bool:
"""Return whether one typed feature was admitted for this sample."""
if not isinstance(feature, RegionalFeature):
raise TypeError("Regional feature query must be a RegionalFeature.")
return feature in self.admitted_features
MODEL_DEPENDENT_REGIONAL_FEATURES = frozenset(
{
RegionalFeature.ATTENTION_COUPLING,
RegionalFeature.SPATIAL_MODEL_PATCH,
RegionalFeature.CONTROL,
RegionalFeature.GLIGEN,
RegionalFeature.REFERENCE_LATENTS,
}
)
def _validate_features(
features: frozenset[RegionalFeature],
*,
value_name: str,
) -> None:
"""Validate one immutable typed regional feature set."""
if not isinstance(features, frozenset):
raise TypeError(f"{value_name} must use an immutable frozenset.")
if not all(isinstance(feature, RegionalFeature) for feature in features):
raise TypeError(f"{value_name} must contain RegionalFeature values.")
EMPTY_REGIONAL_FEATURE_REQUEST = RegionalFeatureRequest()
EMPTY_REGIONAL_CAPABILITY_ADMISSION = RegionalCapabilityAdmission(
request=EMPTY_REGIONAL_FEATURE_REQUEST,
admitted_features=frozenset(),
model_capabilities=None,
)
CONTEXTUAL_DIFFUSION_REGIONAL_SAMPLER_CAPABILITIES = RegionalSamplerCapabilities(
frozenset(
{
RegionalFeature.FULL_CONTEXT_MASKED_CONDITIONING,
RegionalFeature.ATTENTION_COUPLING,
}
)
)
TILED_DIFFUSION_REGIONAL_SAMPLER_CAPABILITIES = RegionalSamplerCapabilities(
frozenset(
{
RegionalFeature.FULL_CONTEXT_MASKED_CONDITIONING,
RegionalFeature.ATTENTION_COUPLING,
}
)
)
@@ -0,0 +1,57 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Project semantic regional-detailing ownership into each inversion resolution."""
from __future__ import annotations
import math
import torch.nn.functional as functional
from .regional_detailing import LatentBox, LatentRegion
def project_inversion_regions(
regions: tuple[LatentRegion, ...],
*,
source_width: int,
source_height: int,
target_width: int,
target_height: int,
) -> tuple[LatentRegion, ...]:
"""Preserve region identity and conditioning while scaling masks and bounds."""
if min(source_width, source_height, target_width, target_height) < 1:
raise ValueError("Regional inversion canvases must have positive dimensions.")
if (source_width, source_height) == (target_width, target_height):
return regions
projected: list[LatentRegion] = []
for region in regions:
box = region.latent_box
if tuple(region.latent_mask.shape) != (source_height, source_width):
raise ValueError("Regional inversion mask must match its canonical canvas.")
if not (
0 <= box.x < box.x + box.width <= source_width
and 0 <= box.y < box.y + box.height <= source_height
):
raise ValueError("Regional inversion bounds must remain inside the canvas.")
left = math.floor(box.x * target_width / source_width)
top = math.floor(box.y * target_height / source_height)
right = math.ceil((box.x + box.width) * target_width / source_width)
bottom = math.ceil((box.y + box.height) * target_height / source_height)
mask = functional.interpolate(
region.latent_mask[None, None].float(),
size=(target_height, target_width),
mode="nearest",
)[0, 0].to(region.latent_mask)
projected.append(
LatentRegion(
region.index,
region.label,
LatentBox(left, top, right - left, bottom - top),
mask,
region.positive,
)
)
return tuple(projected)
+161
View File
@@ -0,0 +1,161 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Own immutable regional model-side LoRA composition plans."""
from __future__ import annotations
import math
from dataclasses import dataclass
from enum import StrEnum
class RegionalLoraBranch(StrEnum):
"""Identify the conditioning branch that owns one regional adapter use."""
POSITIVE = "positive"
NEGATIVE = "negative"
@dataclass(frozen=True)
class RegionalLoraAdapterIdentity:
"""Retain the caller-supplied stable identity of one LoRA artifact."""
value: str
def __post_init__(self) -> None:
"""Reject identities that cannot distinguish an adapter."""
if not isinstance(self.value, str) or not self.value.strip():
raise ValueError(
"Regional LoRA adapter identity must be a non-empty string."
)
@dataclass(frozen=True)
class RegionalLoraScheduleBoundary:
"""Retain one ordered Comfy HookKeyframe boundary exactly."""
start_percent: float
start_sigma: float
strength_multiplier: float
guarantee_steps: int
def __post_init__(self) -> None:
"""Validate one finite normalized schedule boundary."""
_require_finite_float(self.start_percent, name="start_percent")
if not 0.0 <= self.start_percent <= 1.0:
raise ValueError("Regional LoRA schedule start_percent must be in [0, 1].")
_require_finite_float(self.start_sigma, name="start_sigma")
if self.start_sigma < 0.0:
raise ValueError("Regional LoRA schedule start_sigma must be non-negative.")
_require_finite_float(
self.strength_multiplier,
name="strength_multiplier",
)
if isinstance(self.guarantee_steps, bool) or not isinstance(
self.guarantee_steps, int
):
raise TypeError("Regional LoRA guarantee_steps must be an integer.")
if self.guarantee_steps < 0:
raise ValueError("Regional LoRA guarantee_steps must be non-negative.")
@dataclass(frozen=True)
class RegionalLoraAdapterPlan:
"""Describe one ordered regional use of a model-side LoRA adapter."""
adapter_identity: RegionalLoraAdapterIdentity
composition_index: int
region_index: int
branch: RegionalLoraBranch
model_strength: float
schedule: tuple[RegionalLoraScheduleBoundary, ...]
def __post_init__(self) -> None:
"""Validate adapter ownership, strength, and ordered schedule."""
if not isinstance(self.adapter_identity, RegionalLoraAdapterIdentity):
raise TypeError("Regional LoRA adapter_identity has an invalid type.")
_require_non_negative_index(self.composition_index, name="composition_index")
_require_non_negative_index(self.region_index, name="region_index")
if not isinstance(self.branch, RegionalLoraBranch):
raise TypeError("Regional LoRA branch has an invalid type.")
_require_finite_float(self.model_strength, name="model_strength")
if not isinstance(self.schedule, tuple) or not self.schedule:
raise ValueError(
"Regional LoRA schedule must contain at least one boundary."
)
if any(
not isinstance(boundary, RegionalLoraScheduleBoundary)
for boundary in self.schedule
):
raise TypeError("Regional LoRA schedule contains an invalid boundary.")
starts = tuple(boundary.start_percent for boundary in self.schedule)
if starts != tuple(sorted(starts)):
raise ValueError("Regional LoRA schedule boundaries must be ordered.")
sigmas = tuple(boundary.start_sigma for boundary in self.schedule)
if sigmas != tuple(sorted(sigmas, reverse=True)):
raise ValueError(
"Regional LoRA converted schedule boundaries must be descending."
)
@property
def is_time_invariant(self) -> bool:
"""Report whether every keyframe retains one effective multiplier."""
first_multiplier = self.schedule[0].strength_multiplier
return all(
boundary.strength_multiplier == first_multiplier
for boundary in self.schedule[1:]
)
@dataclass(frozen=True)
class RegionalLoraPlan:
"""Store all adapter uses in authoritative global composition order."""
adapters: tuple[RegionalLoraAdapterPlan, ...]
def __post_init__(self) -> None:
"""Require immutable entries with contiguous composition indices."""
if not isinstance(self.adapters, tuple):
raise TypeError("Regional LoRA plan adapters must be a tuple.")
if any(
not isinstance(adapter, RegionalLoraAdapterPlan)
for adapter in self.adapters
):
raise TypeError("Regional LoRA plan contains an invalid adapter entry.")
observed_indices = tuple(adapter.composition_index for adapter in self.adapters)
if observed_indices != tuple(range(len(self.adapters))):
raise ValueError(
"Regional LoRA composition indices must be contiguous and ordered."
)
@property
def is_time_invariant(self) -> bool:
"""Report whether every regional adapter retains one effective strength."""
return all(adapter.is_time_invariant for adapter in self.adapters)
def _require_finite_float(value: object, *, name: str) -> None:
"""Require one exact finite floating-point value."""
if not isinstance(value, float) or not math.isfinite(value):
raise TypeError(f"Regional LoRA {name} must be a finite float.")
def _require_non_negative_index(value: object, *, name: str) -> None:
"""Require one non-negative integer index."""
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"Regional LoRA {name} must be an integer.")
if value < 0:
raise ValueError(f"Regional LoRA {name} must be non-negative.")
EMPTY_REGIONAL_LORA_PLAN = RegionalLoraPlan(adapters=())
+75
View File
@@ -0,0 +1,75 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define the immutable full-canvas authority for regional masks."""
from __future__ import annotations
from dataclasses import dataclass
import torch
@dataclass(frozen=True, slots=True)
class RegionalMaskBank:
"""Hold separate planning and conditioning masks on one latent canvas."""
planning_masks: torch.Tensor
conditioning_masks: torch.Tensor
canvas_width: int
canvas_height: int
def __post_init__(self) -> None:
"""Reject malformed, divergent, or aliased canonical mask tensors."""
if self.canvas_width < 1 or self.canvas_height < 1:
raise ValueError("Regional mask canvas dimensions must be positive.")
self._validate_mask_batch(self.planning_masks, name="Planning")
self._validate_mask_batch(self.conditioning_masks, name="Conditioning")
if self.planning_masks.shape != self.conditioning_masks.shape:
raise ValueError(
"Regional planning and conditioning mask shapes must match."
)
if self.planning_masks.dtype != self.conditioning_masks.dtype:
raise ValueError(
"Regional planning and conditioning mask dtypes must match."
)
if self.planning_masks.device != self.conditioning_masks.device:
raise ValueError(
"Regional planning and conditioning mask devices must match."
)
if (
self.planning_masks.untyped_storage().data_ptr()
== self.conditioning_masks.untyped_storage().data_ptr()
):
raise ValueError(
"Regional planning and conditioning masks must not share storage."
)
@property
def region_count(self) -> int:
"""Return the number of ordered authored regions."""
return int(self.planning_masks.shape[0])
def _validate_mask_batch(self, masks: torch.Tensor, *, name: str) -> None:
"""Validate one normalized floating-point BHW mask batch."""
if not isinstance(masks, torch.Tensor):
raise TypeError(f"{name} regional masks must be a torch.Tensor.")
if masks.ndim != 3:
raise ValueError(f"{name} regional masks must use BHW layout.")
if int(masks.shape[0]) < 1:
raise ValueError(f"{name} regional masks require at least one region.")
if tuple(masks.shape[1:]) != (self.canvas_height, self.canvas_width):
raise ValueError(
f"{name} regional masks must match the full latent canvas "
f"{self.canvas_width}x{self.canvas_height}."
)
if not masks.is_floating_point():
raise TypeError(f"{name} regional masks must use a floating-point dtype.")
if not bool(torch.isfinite(masks).all()):
raise ValueError(f"{name} regional masks must contain only finite values.")
if not bool(((masks >= 0.0) & (masks <= 1.0)).all()):
raise ValueError(f"{name} regional masks must stay within [0, 1].")
@@ -0,0 +1,173 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define immutable capability values for regional attention backends."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class RegionalModelFamily(StrEnum):
"""Identify a defensively admitted regional model family."""
ANIMA = "anima"
STANDARD_UNET = "standard_unet"
class RegionalAttentionBackend(StrEnum):
"""Identify the model-specific attention patch backend."""
ANIMA_OBJECT_PATCH = "anima_object_patch"
UNET_ATTN2_PATCH = "unet_attn2_patch"
class RegionalAttentionTopology(StrEnum):
"""Identify the image/context token roles owned by an attention backend."""
SEPARATE_IMAGE_AND_CONTEXT = "separate_image_and_context"
SINGLETON_FRAME_SPATIOTEMPORAL = "singleton_frame_spatiotemporal"
class RegionalLatentLayout(StrEnum):
"""Identify the latent rank and temporal layout admitted by a backend."""
ANIMA_SINGLE_FRAME_BCTHW = "anima_single_frame_bcthw"
STANDARD_IMAGE_BCHW = "standard_image_bchw"
class RegionalSpatialPatchSupport(StrEnum):
"""Report whether one backend can consume canonical spatial views."""
FULL_AND_SPATIAL_VIEWS = "full_and_spatial_views"
class RegionalControlGligenPolicy(StrEnum):
"""Report control and GLIGEN admission for attention coupling."""
REJECT = "reject"
class RegionalReferenceLatentPolicy(StrEnum):
"""Report reference-latent admission for attention coupling."""
REJECT = "reject"
class RegionalPatchConflict(StrEnum):
"""Identify a patch surface that must be collision-free before mutation."""
DIFFUSION_MODEL_WRAPPER = "diffusion_model_wrapper"
CROSS_ATTENTION_OBJECT_PATCH = "cross_attention_object_patch"
ATTN2_INPUT_PATCH = "attn2_input_patch"
ATTN2_OUTPUT_PATCH = "attn2_output_patch"
@dataclass(frozen=True, slots=True)
class RegionalModelCapabilities:
"""Describe one admitted backend and every relevant compatibility policy."""
model_family: RegionalModelFamily
attention_backend: RegionalAttentionBackend
attention_topology: RegionalAttentionTopology
latent_layout: RegionalLatentLayout
spatial_patch_support: RegionalSpatialPatchSupport
control_gligen_policy: RegionalControlGligenPolicy
reference_latent_policy: RegionalReferenceLatentPolicy
known_patch_conflicts: tuple[RegionalPatchConflict, ...]
def __post_init__(self) -> None:
"""Reject mutable, duplicate, or internally inconsistent capabilities."""
enum_fields = (
("model family", self.model_family, RegionalModelFamily),
("attention backend", self.attention_backend, RegionalAttentionBackend),
(
"attention topology",
self.attention_topology,
RegionalAttentionTopology,
),
("latent layout", self.latent_layout, RegionalLatentLayout),
(
"spatial patch support",
self.spatial_patch_support,
RegionalSpatialPatchSupport,
),
(
"control/GLIGEN policy",
self.control_gligen_policy,
RegionalControlGligenPolicy,
),
(
"reference-latent policy",
self.reference_latent_policy,
RegionalReferenceLatentPolicy,
),
)
for name, value, enum_type in enum_fields:
if not isinstance(value, enum_type):
raise TypeError(
f"Regional {name} must be a {enum_type.__name__} value."
)
if not isinstance(self.known_patch_conflicts, tuple):
raise TypeError(
"Known regional patch conflicts must be an immutable tuple."
)
if not self.known_patch_conflicts:
raise ValueError("Regional capabilities require known patch conflicts.")
if not all(
isinstance(conflict, RegionalPatchConflict)
for conflict in self.known_patch_conflicts
):
raise TypeError(
"Known regional patch conflicts must contain "
"RegionalPatchConflict values."
)
if len(set(self.known_patch_conflicts)) != len(self.known_patch_conflicts):
raise ValueError(
"Known regional patch conflicts must be unique and ordered."
)
self._validate_family_contract()
def _validate_family_contract(self) -> None:
"""Require the exact backend, layout, and conflict surface for a family."""
expected: tuple[
RegionalAttentionBackend,
RegionalAttentionTopology,
RegionalLatentLayout,
tuple[RegionalPatchConflict, ...],
]
if self.model_family is RegionalModelFamily.ANIMA:
expected = (
RegionalAttentionBackend.ANIMA_OBJECT_PATCH,
RegionalAttentionTopology.SINGLETON_FRAME_SPATIOTEMPORAL,
RegionalLatentLayout.ANIMA_SINGLE_FRAME_BCTHW,
(
RegionalPatchConflict.DIFFUSION_MODEL_WRAPPER,
RegionalPatchConflict.CROSS_ATTENTION_OBJECT_PATCH,
RegionalPatchConflict.ATTN2_INPUT_PATCH,
RegionalPatchConflict.ATTN2_OUTPUT_PATCH,
),
)
else:
expected = (
RegionalAttentionBackend.UNET_ATTN2_PATCH,
RegionalAttentionTopology.SEPARATE_IMAGE_AND_CONTEXT,
RegionalLatentLayout.STANDARD_IMAGE_BCHW,
(
RegionalPatchConflict.ATTN2_INPUT_PATCH,
RegionalPatchConflict.ATTN2_OUTPUT_PATCH,
),
)
if (
self.attention_backend,
self.attention_topology,
self.latent_layout,
self.known_patch_conflicts,
) != expected:
raise ValueError(
"Regional model capabilities do not match the model-family contract."
)
+74
View File
@@ -0,0 +1,74 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Pure policies for global-first regional prompt pairing."""
from __future__ import annotations
import math
from dataclasses import dataclass
MAX_REGIONAL_PROMPT_WEIGHT = 1.0
@dataclass(frozen=True)
class RegionalConditioningPair:
"""Map one regional conditioning entry to its authored mask index."""
conditioning_index: int
mask_index: int
@dataclass(frozen=True)
class RegionalConditioningPlan:
"""Describe valid positional pairing after the global entry."""
region_count: int
pairs: tuple[RegionalConditioningPair, ...]
def validate_regional_prompt_weight(weight: float) -> None:
"""Reject regional influence values outside the normalized blend range."""
if not math.isfinite(weight):
raise ValueError("regional_prompt_weight must be finite.")
if not 0.0 <= weight <= MAX_REGIONAL_PROMPT_WEIGHT:
raise ValueError(
"regional_prompt_weight must be between 0.0 and "
f"{MAX_REGIONAL_PROMPT_WEIGHT:.1f}."
)
def build_regional_conditioning_plan(
*,
region_count: int,
conditioning_count: int,
input_name: str,
) -> RegionalConditioningPlan:
"""Return the global-first positional plan or reject excess prompts."""
if region_count < 1:
raise ValueError("regional prompting requires at least one authored mask.")
if conditioning_count < 1:
raise ValueError(
f"{input_name} conditioning must contain a global entry at index 0."
)
regional_count = conditioning_count - 1
if regional_count > region_count:
raise ValueError(
f"{input_name} conditioning contains {regional_count} regional "
f"entries but only {region_count} authored masks were provided."
)
return RegionalConditioningPlan(
region_count=region_count,
pairs=tuple(
RegionalConditioningPair(
conditioning_index=mask_index + 1,
mask_index=mask_index,
)
for mask_index in range(regional_count)
),
)
@@ -0,0 +1,108 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Constrain semantic tiled diffusion by authored regional composition masks."""
from __future__ import annotations
import torch
from .segs import coerce_segs
from .segs_tiled_diffusion import segs_ownership_masks, validate_segs_aspect_ratio
from .semantic_tiled_diffusion import build_semantic_tiled_diffusion_plan
from .tiled_diffusion import TiledDiffusionPlan
REGIONAL_PLANNING_THRESHOLD = 0.5
def build_region_constrained_tiled_diffusion_plan(
*,
region_masks: torch.Tensor,
segs: object | None,
latent_width: int,
latent_height: int,
tile_width: int,
tile_height: int,
overlap: int,
tile_batch_size: int,
segs_canvas: tuple[int, int] | None = None,
) -> TiledDiffusionPlan:
"""Build tiles split wherever regional composition or optional SEGS change."""
region_ownership = regional_composition_ownership_masks(
region_masks,
latent_height=latent_height,
latent_width=latent_width,
)
ownership_masks = region_ownership
if segs is not None:
native_segs = coerce_segs(segs)
canvas_height, canvas_width = segs_canvas or (latent_height, latent_width)
validate_segs_aspect_ratio(native_segs, canvas_height, canvas_width)
semantic_ownership = segs_ownership_masks(
native_segs,
latent_height=latent_height,
latent_width=latent_width,
)
ownership_masks = _intersect_partitions(
region_ownership,
semantic_ownership,
)
return build_semantic_tiled_diffusion_plan(
ownership_masks=ownership_masks,
latent_width=latent_width,
latent_height=latent_height,
tile_width=tile_width,
tile_height=tile_height,
overlap=overlap,
tile_batch_size=tile_batch_size,
merge_across_masks=False,
)
def regional_composition_ownership_masks(
region_masks: torch.Tensor,
*,
latent_height: int,
latent_width: int,
) -> tuple[torch.Tensor, ...]:
"""Partition the canvas by every distinct active regional-mask combination."""
if region_masks.ndim != 3:
raise ValueError("Regional tile planning requires a BHW mask batch.")
if tuple(region_masks.shape[1:]) != (latent_height, latent_width):
raise ValueError(
"Regional tile planning masks must match latent shape "
f"{latent_height}x{latent_width}."
)
membership = (region_masks.detach().cpu() >= REGIONAL_PLANNING_THRESHOLD).permute(
1, 2, 0
)
flattened = membership.reshape(latent_height * latent_width, -1)
signatures, inverse = torch.unique(
flattened,
dim=0,
sorted=True,
return_inverse=True,
)
del signatures
labels = inverse.reshape(latent_height, latent_width)
return tuple(labels == index for index in range(int(labels.max().item()) + 1))
def _intersect_partitions(
first: tuple[torch.Tensor, ...],
second: tuple[torch.Tensor, ...],
) -> tuple[torch.Tensor, ...]:
"""Return non-empty intersections of two complete ownership partitions."""
intersections = tuple(
intersection
for first_mask in first
for second_mask in second
if bool((intersection := torch.logical_and(first_mask, second_mask)).any())
)
if not intersections:
raise ValueError("Regional and SEGS ownership produced no tile coverage.")
return intersections
@@ -0,0 +1,347 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define model-neutral resolved regional ordinary-LoRA operation contracts."""
from __future__ import annotations
import math
from dataclasses import dataclass
from enum import StrEnum
from .regional_lora_plan import RegionalLoraAdapterPlan
class ResolvedLoraOperationClass(StrEnum):
"""Classify normalized low-rank tensor organization before module binding."""
MATRIX_PAIR = "matrix_pair"
CONVOLUTION_1D = "convolution_1d"
CONVOLUTION_2D = "convolution_2d"
CONVOLUTION_3D = "convolution_3d"
UNSUPPORTED = "unsupported"
class RegionalLoraGeometryClass(StrEnum):
"""Declare the activation geometry an execution contract requires."""
TARGET_OPERATION = "target_operation"
DIRECT_CONVOLUTION_1D = "direct_convolution_1d"
DIRECT_CONVOLUTION_2D = "direct_convolution_2d"
DIRECT_CONVOLUTION_3D = "direct_convolution_3d"
UNSUPPORTED = "unsupported"
class RegionalLoraExecutionContract(StrEnum):
"""Declare exact rank-activation execution or explicit rejection."""
MATRIX_OR_RESHAPED_CONVOLUTION = "matrix_or_reshaped_convolution"
DIRECT_CONVOLUTION = "direct_convolution"
UNSUPPORTED = "unsupported"
@dataclass(frozen=True, slots=True)
class RegionalLoraTensorShape:
"""Retain one immutable positive tensor shape without tensor ownership."""
dimensions: tuple[int, ...]
def __post_init__(self) -> None:
"""Require a nonempty tuple of strictly positive integer dimensions."""
if not isinstance(self.dimensions, tuple) or not self.dimensions:
raise ValueError("Regional LoRA tensor shape must be a nonempty tuple.")
if any(
isinstance(dimension, bool)
or not isinstance(dimension, int)
or dimension <= 0
for dimension in self.dimensions
):
raise ValueError(
"Regional LoRA tensor shape dimensions must be positive integers."
)
@property
def rank(self) -> int:
"""Return the tensor dimensionality."""
return len(self.dimensions)
@dataclass(frozen=True, slots=True)
class ResolvedRegionalLoraTarget:
"""Retain one model-relative parameter path and optional Comfy tensor slice."""
model_target: str
parameter_name: str
offset: tuple[int, ...] | None
def __post_init__(self) -> None:
"""Require an explicit module target, parameter, and valid optional slice."""
if not isinstance(self.model_target, str) or not self.model_target.strip():
raise ValueError("Resolved regional LoRA model target must be nonempty.")
if not isinstance(self.parameter_name, str) or not self.parameter_name.strip():
raise ValueError("Resolved regional LoRA parameter name must be nonempty.")
if self.offset is not None and (
not isinstance(self.offset, tuple)
or not self.offset
or any(
isinstance(part, bool) or not isinstance(part, int) or part < 0
for part in self.offset
)
):
raise ValueError(
"Resolved regional LoRA target offset must contain "
"non-negative integers."
)
@property
def parameter_path(self) -> str:
"""Return the complete model-relative parameter path."""
return f"{self.model_target}.{self.parameter_name}"
@dataclass(frozen=True, slots=True)
class ResolvedRegionalLoraOperation:
"""Describe one supported operation or one explicit target rejection."""
adapter: RegionalLoraAdapterPlan
target_index: int
target: ResolvedRegionalLoraTarget
normalized_operation_type: str
operation_class: ResolvedLoraOperationClass
down_shape: RegionalLoraTensorShape | None
up_shape: RegionalLoraTensorShape | None
middle_shape: RegionalLoraTensorShape | None
reshape_shape: RegionalLoraTensorShape | None
rank: int | None
intrinsic_scale: float | None
required_geometry: RegionalLoraGeometryClass
execution_contract: RegionalLoraExecutionContract
rejection_reason: str | None
def __post_init__(self) -> None:
"""Reject mutable, incomplete, or mathematically inconsistent metadata."""
if not isinstance(self.adapter, RegionalLoraAdapterPlan):
raise TypeError("Resolved regional LoRA operation requires an adapter.")
_require_non_negative_index(self.target_index, name="target_index")
if not isinstance(self.target, ResolvedRegionalLoraTarget):
raise TypeError("Resolved regional LoRA operation requires a target.")
if (
not isinstance(self.normalized_operation_type, str)
or not self.normalized_operation_type.strip()
):
raise ValueError(
"Normalized regional LoRA operation type must be nonempty."
)
if not isinstance(self.operation_class, ResolvedLoraOperationClass):
raise TypeError("Resolved regional LoRA operation class is invalid.")
if not isinstance(self.required_geometry, RegionalLoraGeometryClass):
raise TypeError("Resolved regional LoRA geometry class is invalid.")
if not isinstance(self.execution_contract, RegionalLoraExecutionContract):
raise TypeError("Resolved regional LoRA execution contract is invalid.")
if self.execution_contract is RegionalLoraExecutionContract.UNSUPPORTED:
self._validate_rejection()
return
self._validate_supported()
def _validate_rejection(self) -> None:
"""Require one reason and no misleading supported-operation metadata."""
if self.operation_class is not ResolvedLoraOperationClass.UNSUPPORTED:
raise ValueError(
"Rejected regional LoRA operation class must be unsupported."
)
if self.required_geometry is not RegionalLoraGeometryClass.UNSUPPORTED:
raise ValueError("Rejected regional LoRA geometry must be unsupported.")
if (
not isinstance(self.rejection_reason, str)
or not self.rejection_reason.strip()
):
raise ValueError("Rejected regional LoRA operation requires a reason.")
if any(
value is not None
for value in (
self.down_shape,
self.up_shape,
self.middle_shape,
self.reshape_shape,
self.rank,
self.intrinsic_scale,
)
):
raise ValueError(
"Rejected regional LoRA operation cannot claim executable metadata."
)
def _validate_supported(self) -> None:
"""Require complete shape, scale, geometry, and execution consistency."""
if self.operation_class is ResolvedLoraOperationClass.UNSUPPORTED:
raise ValueError("Supported regional LoRA operation cannot be unsupported.")
if self.required_geometry is RegionalLoraGeometryClass.UNSUPPORTED:
raise ValueError("Supported regional LoRA geometry cannot be unsupported.")
if self.rejection_reason is not None:
raise ValueError(
"Supported regional LoRA operation cannot have a rejection."
)
if self.down_shape is None or self.up_shape is None:
raise ValueError(
"Supported regional LoRA operation requires down/up shapes."
)
if self.down_shape.rank < 2 or self.up_shape.rank < 2:
raise ValueError(
"Regional LoRA down/up shapes need at least two dimensions."
)
if (
isinstance(self.rank, bool)
or not isinstance(self.rank, int)
or self.rank <= 0
):
raise ValueError("Supported regional LoRA rank must be a positive integer.")
if not isinstance(self.intrinsic_scale, float) or not math.isfinite(
self.intrinsic_scale
):
raise ValueError("Regional LoRA intrinsic scale must be finite.")
if self.down_shape.dimensions[0] != self.rank:
raise ValueError("Regional LoRA down shape must begin with its rank.")
if (
len(self.up_shape.dimensions) < 2
or self.up_shape.dimensions[1] != self.rank
):
raise ValueError(
"Regional LoRA up shape must contain its rank at index one."
)
if self.middle_shape is not None:
if self.middle_shape.rank < 2:
raise ValueError(
"Regional LoRA middle shape needs at least two dimensions."
)
if (
self.middle_shape.dimensions[0] != self.rank
or self.middle_shape.dimensions[1] != self.rank
):
raise ValueError(
"Regional LoRA middle shape must preserve rank channels."
)
self._validate_operation_shape_contract()
def _validate_operation_shape_contract(self) -> None:
"""Match operation, tensor dimensionality, geometry, and execution policy."""
if self.operation_class is ResolvedLoraOperationClass.MATRIX_PAIR:
if self.down_shape is None or self.up_shape is None:
raise AssertionError(
"Supported shape validation requires down/up shapes."
)
if self.down_shape.rank != 2 or self.up_shape.rank != 2:
raise ValueError("Matrix-pair regional LoRA tensors must be rank two.")
if self.middle_shape is not None:
raise ValueError(
"Matrix-pair regional LoRA cannot contain middle weights."
)
if self.required_geometry is not RegionalLoraGeometryClass.TARGET_OPERATION:
raise ValueError("Matrix-pair regional LoRA requires target geometry.")
if (
self.execution_contract
is not RegionalLoraExecutionContract.MATRIX_OR_RESHAPED_CONVOLUTION
):
raise ValueError("Matrix-pair regional LoRA execution is inconsistent.")
return
spatial_rank = {
ResolvedLoraOperationClass.CONVOLUTION_1D: 3,
ResolvedLoraOperationClass.CONVOLUTION_2D: 4,
ResolvedLoraOperationClass.CONVOLUTION_3D: 5,
}[self.operation_class]
expected_geometry = {
3: RegionalLoraGeometryClass.DIRECT_CONVOLUTION_1D,
4: RegionalLoraGeometryClass.DIRECT_CONVOLUTION_2D,
5: RegionalLoraGeometryClass.DIRECT_CONVOLUTION_3D,
}[spatial_rank]
shapes = tuple(
shape
for shape in (self.down_shape, self.up_shape, self.middle_shape)
if shape is not None
)
if any(shape.rank not in (2, spatial_rank) for shape in shapes):
raise ValueError("Convolutional regional LoRA tensor rank is inconsistent.")
if not any(shape.rank == spatial_rank for shape in shapes):
raise ValueError("Convolutional regional LoRA needs one spatial tensor.")
if self.required_geometry is not expected_geometry:
raise ValueError("Convolutional regional LoRA geometry is inconsistent.")
if (
self.execution_contract
is not RegionalLoraExecutionContract.DIRECT_CONVOLUTION
):
raise ValueError("Convolutional regional LoRA execution is inconsistent.")
@property
def authored_strength(self) -> float:
"""Return the authored model strength from the authoritative plan owner."""
return self.adapter.model_strength
@property
def supported(self) -> bool:
"""Report whether this target has an executable ordinary-LoRA contract."""
return self.execution_contract is not RegionalLoraExecutionContract.UNSUPPORTED
@dataclass(frozen=True, slots=True)
class ResolvedRegionalLoraOperationSet:
"""Retain canonical adapter/target order across supported and rejected entries."""
entries: tuple[ResolvedRegionalLoraOperation, ...]
def __post_init__(self) -> None:
"""Require immutable entries in contiguous per-adapter target order."""
if not isinstance(self.entries, tuple):
raise TypeError("Resolved regional LoRA entries must be a tuple.")
if any(
not isinstance(entry, ResolvedRegionalLoraOperation)
for entry in self.entries
):
raise TypeError("Resolved regional LoRA set contains an invalid entry.")
observed = tuple(
(entry.adapter.composition_index, entry.target_index)
for entry in self.entries
)
if observed != tuple(sorted(observed)):
raise ValueError(
"Resolved regional LoRA entries must retain declared order."
)
indices_by_adapter: dict[int, list[int]] = {}
for composition_index, target_index in observed:
indices_by_adapter.setdefault(composition_index, []).append(target_index)
if any(
target_indices != list(range(len(target_indices)))
for target_indices in indices_by_adapter.values()
):
raise ValueError(
"Resolved regional LoRA target indices must be contiguous per adapter."
)
@property
def supported(self) -> tuple[ResolvedRegionalLoraOperation, ...]:
"""Return supported entries without changing canonical relative order."""
return tuple(entry for entry in self.entries if entry.supported)
@property
def rejected(self) -> tuple[ResolvedRegionalLoraOperation, ...]:
"""Return rejected entries without changing canonical relative order."""
return tuple(entry for entry in self.entries if not entry.supported)
def _require_non_negative_index(value: object, *, name: str) -> None:
"""Require one non-negative non-boolean integer index."""
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(f"Resolved regional LoRA {name} must be non-negative.")
+162
View File
@@ -0,0 +1,162 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Assemble immutable, order-independent sampler capability configuration."""
from __future__ import annotations
import math
from dataclasses import dataclass, replace
from typing import TypeAlias
from .noise_inversion import NoiseInversionOptions
from .regional_prompting import validate_regional_prompt_weight
from .tiled_diffusion import validate_tiled_diffusion_mode
@dataclass(frozen=True, slots=True)
class TilingOptions:
"""Own the single local tile layout and blending configuration."""
diffusion_mode: str = "multidiffusion"
width: int = 128
height: int = 128
overlap: int = 32
batch_size: int = 4
differential_diffusion: bool = False
def __post_init__(self) -> None:
"""Reject tile settings that cannot form a bounded prediction plan."""
validate_tiled_diffusion_mode(self.diffusion_mode)
for name, value in (("width", self.width), ("height", self.height)):
if type(value) is not int or not 16 <= value <= 512:
raise ValueError(
f"Tile {name} must be between 16 and 512 latent pixels."
)
if type(self.overlap) is not int or not 0 <= self.overlap < min(
self.width, self.height
):
raise ValueError(
"Tile overlap must be nonnegative and smaller than both dimensions."
)
if type(self.batch_size) is not int or self.batch_size < 1:
raise ValueError("Tile batch size must be a positive integer.")
if type(self.differential_diffusion) is not bool:
raise TypeError("Differential diffusion must be a boolean.")
@dataclass(frozen=True, slots=True)
class ContextualDiffusionOptions:
"""Own global context and square local sampling geometry independently of tiling."""
context_size: int = 96
global_weight: float = 1.0
global_steps: int = 1
global_decay: float = 0.5
diffusion_mode: str = "multidiffusion"
overlap: int = 32
batch_size: int = 4
differential_diffusion: bool = False
def __post_init__(self) -> None:
"""Reject invalid global schedules and square local sampling settings."""
if type(self.context_size) is not int or not 16 <= self.context_size <= 512:
raise ValueError("Context size must be between 16 and 512 latent pixels.")
if not math.isfinite(self.global_weight) or not 0 <= self.global_weight <= 2:
raise ValueError("Global context weight must be between 0 and 2.")
if type(self.global_steps) is not int or self.global_steps < 0:
raise ValueError("Global context steps must be a nonnegative integer.")
if not math.isfinite(self.global_decay) or not 0 <= self.global_decay <= 1:
raise ValueError("Global context decay must be between 0 and 1.")
self.local_tiling()
def local_tiling(self) -> TilingOptions:
"""Use context size for both dimensions of the sole local sampling plan."""
return TilingOptions(
diffusion_mode=self.diffusion_mode,
width=self.context_size,
height=self.context_size,
overlap=self.overlap,
batch_size=self.batch_size,
differential_diffusion=self.differential_diffusion,
)
@dataclass(frozen=True, slots=True)
class AttentionCouplingOptions:
"""Configure regional attention strength without binding masks or a model."""
regional_prompt_weight: float = 1.0
region_mask_feather: int = 0
def __post_init__(self) -> None:
"""Require valid regional attention strengths and feathering controls."""
validate_regional_prompt_weight(self.regional_prompt_weight)
if type(self.region_mask_feather) is not int or self.region_mask_feather < 0:
raise ValueError("Region mask feather must be a nonnegative integer.")
SamplerCapability: TypeAlias = (
TilingOptions
| ContextualDiffusionOptions
| NoiseInversionOptions
| AttentionCouplingOptions
)
@dataclass(frozen=True, slots=True)
class SamplerOptions:
"""Own one immutable setting per capability, independent of graph order."""
tiling: TilingOptions | None = None
contextual_diffusion: ContextualDiffusionOptions | None = None
noise_inversion: NoiseInversionOptions | None = None
attention_coupling: AttentionCouplingOptions | None = None
def __post_init__(self) -> None:
"""Reject malformed connection payloads at the typed configuration boundary."""
for name, expected in (
("tiling", TilingOptions),
("contextual_diffusion", ContextualDiffusionOptions),
("noise_inversion", NoiseInversionOptions),
("attention_coupling", AttentionCouplingOptions),
):
value = getattr(self, name)
if value is not None and not isinstance(value, expected):
raise TypeError(f"Sampler option {name} must be {expected.__name__}.")
def with_capability(self, capability: SamplerCapability) -> SamplerOptions:
"""Return a fresh configuration or reject an ambiguous duplicate feature."""
names: dict[type[object], str] = {
TilingOptions: "tiling",
ContextualDiffusionOptions: "contextual_diffusion",
NoiseInversionOptions: "noise_inversion",
AttentionCouplingOptions: "attention_coupling",
}
name = names.get(type(capability))
if name is None:
raise TypeError("Unsupported sampler capability configuration.")
if getattr(self, name) is not None:
raise ValueError(
f"Duplicate sampler capability: {name}. Bypass or remove one node."
)
if isinstance(capability, TilingOptions):
return replace(self, tiling=capability)
if isinstance(capability, ContextualDiffusionOptions):
return replace(self, contextual_diffusion=capability)
if isinstance(capability, NoiseInversionOptions):
return replace(self, noise_inversion=capability)
return replace(self, attention_coupling=capability)
def append_sampler_capability(
options: SamplerOptions | None, capability: SamplerCapability | None
) -> SamplerOptions:
"""Append a capability or pass through a disabled contribution after validation."""
if options is not None and not isinstance(options, SamplerOptions):
raise TypeError(
"Options input must be a SimpleSyrup sampler options connection."
)
current = options if options is not None else SamplerOptions()
return current if capability is None else current.with_capability(capability)
+54
View File
@@ -0,0 +1,54 @@
# 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 seed-variation sampling settings."""
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
MIN_SEED = 0
MAX_SEED = 0xFFFFFFFFFFFFFFFF
MIN_VARIATION_STRENGTH = 0.0
MAX_VARIATION_STRENGTH = 1.0
@dataclass(frozen=True, slots=True)
class SeedVariationSettings:
"""Hold one deterministic initial-noise interpolation request."""
variation_seed: int
strength: float
def __post_init__(self) -> None:
"""Reject settings outside ComfyUI's public seed and strength ranges."""
if isinstance(self.variation_seed, bool) or not isinstance(
self.variation_seed,
int,
):
raise TypeError("Variation seed must be an integer.")
if not MIN_SEED <= self.variation_seed <= MAX_SEED:
raise ValueError(
f"Variation seed must be between {MIN_SEED} and {MAX_SEED}."
)
if isinstance(self.strength, bool) or not isinstance(
self.strength,
(int, float),
):
raise TypeError("Variation strength must be a number.")
normalized_strength = float(self.strength)
if not isfinite(normalized_strength):
raise ValueError("Variation strength must be finite.")
if (
not MIN_VARIATION_STRENGTH
<= normalized_strength
<= (MAX_VARIATION_STRENGTH)
):
raise ValueError(
"Variation strength must be between "
f"{MIN_VARIATION_STRENGTH} and {MAX_VARIATION_STRENGTH}."
)
object.__setattr__(self, "strength", normalized_strength)
+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, ...]
+118
View File
@@ -0,0 +1,118 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Plan deterministic visual representations of validated SEGS."""
from __future__ import annotations
from dataclasses import dataclass
from typing import NamedTuple
import torch
from .segs import CropRegion, NativeSegs, Segment, coerce_segment_mask, coerce_segs
class RegionColor(NamedTuple):
"""Represent one reusable RGB region color."""
red: int
green: int
blue: int
@property
def normalized(self) -> tuple[float, float, float]:
"""Return the color as normalized tensor-ready channels."""
return self.red / 255.0, self.green / 255.0, self.blue / 255.0
@property
def css(self) -> str:
"""Return the color as a browser-ready hexadecimal value."""
return f"#{self.red:02x}{self.green:02x}{self.blue:02x}"
REGION_COLORS: tuple[RegionColor, ...] = (
RegionColor(242, 66, 54),
RegionColor(33, 150, 243),
RegionColor(76, 176, 80),
RegionColor(255, 194, 8),
RegionColor(156, 39, 176),
RegionColor(255, 87, 34),
RegionColor(0, 188, 212),
RegionColor(232, 31, 99),
RegionColor(140, 194, 74),
RegionColor(103, 58, 183),
RegionColor(255, 153, 0),
RegionColor(0, 150, 136),
)
@dataclass(frozen=True)
class VisualRegion:
"""Describe one validated SEG with stable visualization identity."""
region_id: str
index: int
segment: Segment
mask: torch.Tensor
color: RegionColor
active_area: int
@property
def crop_region(self) -> CropRegion:
"""Return the source crop occupied by this region."""
return self.segment.crop_region
@dataclass(frozen=True)
class SegVisualizationPlan:
"""Own source geometry and ordered visual regions for one SEGS payload."""
source_height: int
source_width: int
regions: tuple[VisualRegion, ...]
def build_seg_visualization_plan(segs: NativeSegs) -> SegVisualizationPlan:
"""Return deterministic validated regions without mutating the source SEGS."""
(source_height, source_width), segments = coerce_segs(segs)
regions: list[VisualRegion] = []
for index, segment in enumerate(segments):
_validate_crop_bounds(
segment.crop_region,
source_height=source_height,
source_width=source_width,
)
mask = coerce_segment_mask(segment).detach().cpu()
regions.append(
VisualRegion(
region_id=f"seg-{index + 1:04d}",
index=index,
segment=segment,
mask=mask,
color=REGION_COLORS[index % len(REGION_COLORS)],
active_area=int((mask >= 0.5).sum().item()),
)
)
return SegVisualizationPlan(
source_height=source_height,
source_width=source_width,
regions=tuple(regions),
)
def _validate_crop_bounds(
crop: CropRegion,
*,
source_height: int,
source_width: int,
) -> None:
"""Reject SEG crops that cannot describe the declared source image."""
if crop.right > source_width or crop.bottom > source_height:
raise ValueError("Segment crop_region must fit inside the SEGS dimensions.")
+54
View File
@@ -10,6 +10,8 @@ from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import NamedTuple, Protocol, TypeAlias, cast
import torch
class CropRegion(NamedTuple):
"""Represent a crop region as left, top, right, bottom coordinates."""
@@ -152,6 +154,25 @@ def coerce_segment(value: object) -> Segment:
)
def coerce_segment_mask(segment: Segment) -> torch.Tensor:
"""Return one validated crop-local HW mask without changing its values."""
mask = (
segment.cropped_mask.float()
if isinstance(segment.cropped_mask, torch.Tensor)
else torch.as_tensor(segment.cropped_mask, dtype=torch.float32)
)
if mask.ndim == 3 and int(mask.shape[0]) == 1:
mask = mask.squeeze(0)
if mask.ndim != 2:
raise ValueError("Segment cropped_mask must be HW or singleton BHW shaped.")
expected_shape = (segment.crop_region.height, segment.crop_region.width)
actual_shape = (int(mask.shape[0]), int(mask.shape[1]))
if actual_shape != expected_shape:
raise ValueError("Segment cropped_mask must match its crop region.")
return mask.clamp(0.0, 1.0)
def to_impact_compatible_segs(segs: NativeSegs) -> ImpactSegs:
"""Return raw tuple/list SEGS that Impact-style consumers can read."""
@@ -184,6 +205,32 @@ def to_impact_compatible_segs_group(segs_group: NativeSegsGroup) -> list[ImpactS
return [to_impact_compatible_segs(segs) for segs in segs_group]
def batch_segs(values: Iterable[object]) -> NativeSegs:
"""Return one SEGS payload containing all segments in input order."""
raw_values = tuple(values)
if not raw_values:
raise ValueError("Batch SEGS requires one or more SEGS inputs.")
expected_header: SegsHeader | None = None
batched_segments: list[Segment] = []
for index, value in enumerate(raw_values, start=1):
header, segments = coerce_segs(value)
if expected_header is None:
expected_header = header
elif header != expected_header:
raise ValueError(
"Batch SEGS requires all SEGS inputs to use the same image size; "
f"input {index} is {_format_header(header)} but input 1 is "
f"{_format_header(expected_header)}."
)
batched_segments.extend(segments)
if expected_header is None:
raise ValueError("Batch SEGS requires one or more SEGS inputs.")
return expected_header, tuple(batched_segments)
def limit_segs(segs: NativeSegs, keep_only: int, keep_by: str) -> NativeSegs:
"""Return SEGS limited by a user-facing ranking policy."""
@@ -284,6 +331,13 @@ def _coerce_header(value: object) -> SegsHeader:
return height, width
def _format_header(header: SegsHeader) -> str:
"""Return a height-first image size description."""
height, width = header
return f"{height}x{width}"
def _looks_like_segs(value: object) -> bool:
"""Return whether a value has the outer shape of one SEGS payload."""
@@ -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:
+216
View File
@@ -0,0 +1,216 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Build irregular, SEGS-guided latent tiles for tiled diffusion sampling."""
from __future__ import annotations
import torch
from .segs import NativeSegs, Segment, coerce_segment_mask, coerce_segs
from .semantic_tiled_diffusion import build_semantic_tiled_diffusion_plan
from .tiled_diffusion import TiledDiffusionPlan
def build_segs_guided_tiled_diffusion_plan(
*,
segs: object,
latent_width: int,
latent_height: int,
tile_width: int,
tile_height: int,
overlap: int,
tile_batch_size: int,
segs_canvas: tuple[int, int] | None = None,
) -> TiledDiffusionPlan:
"""Build bounded sampling windows whose irregular cores follow supplied SEGS.
Every latent pixel receives exactly one ownership core. Each core is sampled
through a rectangular window, while its local blend mask retains the irregular
boundary and shares a feathered overlap with neighboring cores.
A reduced inversion stage validates proportions against its original canvas
because rounding the reduced dimensions can change their aspect ratio.
"""
native_segs = coerce_segs(segs)
canvas_height, canvas_width = segs_canvas or (latent_height, latent_width)
validate_segs_aspect_ratio(native_segs, canvas_height, canvas_width)
ownership_masks = segs_ownership_masks(
native_segs,
latent_height=latent_height,
latent_width=latent_width,
)
return build_semantic_tiled_diffusion_plan(
ownership_masks=ownership_masks,
latent_width=latent_width,
latent_height=latent_height,
tile_width=tile_width,
tile_height=tile_height,
overlap=overlap,
tile_batch_size=tile_batch_size,
merge_across_masks=True,
)
def validate_segs_aspect_ratio(
segs: NativeSegs,
latent_height: int,
latent_width: int,
) -> None:
"""Reject SEGS that cannot describe the sampled latent's image proportions."""
source_height, source_width = segs[0]
source_ratio = source_width / source_height
latent_ratio = latent_width / latent_height
if abs(source_ratio - latent_ratio) / source_ratio <= 0.02:
return
raise ValueError(
"SEGS-guided tiled diffusion requires SEGS to match the latent image "
f"aspect ratio; SEGS is {source_height}x{source_width}, latent is "
f"{latent_height}x{latent_width}."
)
def segs_ownership_masks(
segs: NativeSegs,
*,
latent_height: int,
latent_width: int,
) -> tuple[torch.Tensor, ...]:
"""Resolve overlapping SEGS into a deterministic latent ownership partition."""
source_height, source_width = segs[0]
segment_masks = tuple(
segment_mask_to_latent(
segment,
source_height=source_height,
source_width=source_width,
latent_height=latent_height,
latent_width=latent_width,
)
for segment in segs[1]
)
ranked_indexes = sorted(
range(len(segment_masks)),
key=lambda index: (
int(segment_masks[index].sum().item()),
-float(segs[1][index].confidence),
index,
),
)
occupied = torch.zeros((latent_height, latent_width), dtype=torch.bool)
ownership_masks: list[torch.Tensor] = []
for index in ranked_indexes:
owned = torch.logical_and(segment_masks[index], torch.logical_not(occupied))
if bool(owned.any()):
ownership_masks.append(owned)
occupied = torch.logical_or(occupied, segment_masks[index])
background = torch.logical_not(occupied)
if bool(background.any()):
ownership_masks.append(background)
if ownership_masks:
return tuple(ownership_masks)
return (torch.ones((latent_height, latent_width), dtype=torch.bool),)
def segment_mask_to_latent(
segment: Segment,
*,
source_height: int,
source_width: int,
latent_height: int,
latent_width: int,
) -> torch.Tensor:
"""Restore one crop-local SEG mask and map it to a latent-space mask."""
return (
segment_weight_to_latent(
segment,
source_height=source_height,
source_width=source_width,
latent_height=latent_height,
latent_width=latent_width,
)
>= 0.5
)
def segment_weight_to_latent(
segment: Segment,
*,
source_height: int,
source_width: int,
latent_height: int,
latent_width: int,
) -> torch.Tensor:
"""Project one crop-local SEG mask into latent space without binarizing it."""
crop = segment.crop_region
if (
crop.left < 0
or crop.top < 0
or crop.right > source_width
or crop.bottom > source_height
or crop.width < 1
or crop.height < 1
):
raise ValueError(
"SEGS-guided tiled diffusion requires every SEG crop_region to fit "
"inside the SEGS header dimensions."
)
local_mask = coerce_segment_mask(segment).detach().cpu()
latent_top, latent_bottom = _latent_sample_range(
crop.top,
crop.bottom,
source_height,
latent_height,
)
latent_left, latent_right = _latent_sample_range(
crop.left,
crop.right,
source_width,
latent_width,
)
latent_mask = torch.zeros((latent_height, latent_width), dtype=torch.float32)
if latent_bottom <= latent_top or latent_right <= latent_left:
return latent_mask
sampled_rows = (
torch.div(
torch.arange(latent_top, latent_bottom) * source_height,
latent_height,
rounding_mode="floor",
)
- crop.top
)
sampled_columns = (
torch.div(
torch.arange(latent_left, latent_right) * source_width,
latent_width,
rounding_mode="floor",
)
- crop.left
)
sampled_mask = (
local_mask.clamp(0.0, 1.0)
.index_select(
0,
sampled_rows,
)
.index_select(1, sampled_columns)
)
latent_mask[latent_top:latent_bottom, latent_left:latent_right] = sampled_mask
return latent_mask
def _latent_sample_range(
source_start: int,
source_end: int,
source_limit: int,
latent_limit: int,
) -> tuple[int, int]:
"""Return latent coordinates whose nearest samples fall in a source interval."""
start = (source_start * latent_limit + source_limit - 1) // source_limit
end = (source_end * latent_limit + source_limit - 1) // source_limit
return max(0, min(latent_limit, start)), max(0, min(latent_limit, end))
@@ -0,0 +1,384 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Build tiled diffusion plans from non-overlapping latent ownership masks."""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
import torch
import torch.nn.functional as functional
from .tiled_diffusion import (
LatentTile,
TiledDiffusionPlan,
batch_latent_tiles,
build_tiled_diffusion_plan,
)
@dataclass(frozen=True)
class _OwnershipCore:
"""Represent one latent ownership region before sampling-window placement."""
mask: torch.Tensor
bounds: tuple[int, int, int, int]
area: int
def build_semantic_tiled_diffusion_plan(
*,
ownership_masks: Sequence[torch.Tensor],
latent_width: int,
latent_height: int,
tile_width: int,
tile_height: int,
overlap: int,
tile_batch_size: int,
merge_across_masks: bool,
) -> TiledDiffusionPlan:
"""Build bounded windows whose write weights follow ownership masks."""
base_plan = build_tiled_diffusion_plan(
latent_width=latent_width,
latent_height=latent_height,
tile_width=tile_width,
tile_height=tile_height,
overlap=overlap,
tile_batch_size=tile_batch_size,
)
normalized_masks = _validate_ownership_masks(
ownership_masks,
latent_height=latent_height,
latent_width=latent_width,
)
max_core_width = max(1, base_plan.tile_width - base_plan.overlap)
max_core_height = max(1, base_plan.tile_height - base_plan.overlap)
split_groups = tuple(
tuple(
split_core
for split_core in _split_core(
_core_from_mask(mask),
max_width=max_core_width,
max_height=max_core_height,
)
)
for mask in normalized_masks
)
if merge_across_masks:
cores = _merge_small_cores(
tuple(core for group in split_groups for core in group),
max_width=max_core_width,
max_height=max_core_height,
)
else:
cores = tuple(
core
for group in split_groups
for core in _merge_small_cores(
group,
max_width=max_core_width,
max_height=max_core_height,
)
)
tiles = tuple(
sorted(
(
_tile_for_core(
core,
latent_width=latent_width,
latent_height=latent_height,
tile_width=base_plan.tile_width,
tile_height=base_plan.tile_height,
overlap=base_plan.overlap,
)
for core in cores
),
key=lambda tile: (tile.y, tile.x),
)
)
batches, effective_batch_size = batch_latent_tiles(tiles, tile_batch_size)
return TiledDiffusionPlan(
latent_width=latent_width,
latent_height=latent_height,
tile_width=base_plan.tile_width,
tile_height=base_plan.tile_height,
overlap=base_plan.overlap,
requested_tile_batch_size=tile_batch_size,
tile_batch_size=effective_batch_size,
tiles=tiles,
batches=batches,
)
def _validate_ownership_masks(
masks: Sequence[torch.Tensor],
*,
latent_height: int,
latent_width: int,
) -> tuple[torch.Tensor, ...]:
"""Return non-empty boolean masks that cover the complete latent canvas."""
normalized: list[torch.Tensor] = []
coverage = torch.zeros((latent_height, latent_width), dtype=torch.bool)
for index, mask in enumerate(masks):
if mask.ndim != 2 or tuple(mask.shape) != (latent_height, latent_width):
raise ValueError(
"Semantic ownership mask "
f"{index} must match latent shape {latent_height}x{latent_width}."
)
boolean_mask = mask.detach().cpu().bool()
if not bool(boolean_mask.any()):
continue
if bool(torch.logical_and(coverage, boolean_mask).any()):
raise ValueError("Semantic ownership masks must not overlap.")
normalized.append(boolean_mask)
coverage = torch.logical_or(coverage, boolean_mask)
if not normalized:
raise ValueError("Semantic tiled diffusion requires non-empty ownership.")
if not bool(coverage.all()):
raise ValueError("Semantic ownership masks must cover the latent canvas.")
return tuple(normalized)
def _split_core(
core: _OwnershipCore,
*,
max_width: int,
max_height: int,
) -> tuple[_OwnershipCore, ...]:
"""Recursively divide a core into balanced pieces within its tile budget."""
left, top, right, bottom = core.bounds
width = right - left
height = bottom - top
if width <= max_width and height <= max_height:
return (core,)
split_x = width / max_width >= height / max_height
first, second = _split_mask_at_balanced_axis(core.mask, core.bounds, split_x)
return _split_core(
_core_from_mask(first), max_width=max_width, max_height=max_height
) + _split_core(_core_from_mask(second), max_width=max_width, max_height=max_height)
def _split_mask_at_balanced_axis(
mask: torch.Tensor,
bounds: tuple[int, int, int, int],
split_x: bool,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Split one non-empty mask near its active-pixel median on one axis."""
left, top, right, bottom = bounds
counts = (
mask[top:bottom, left:right].sum(dim=0)
if split_x
else mask[top:bottom, left:right].sum(dim=1)
)
cumulative = torch.cumsum(counts, dim=0)
midpoint = int(torch.searchsorted(cumulative, cumulative[-1] / 2, right=False))
axis_start = left if split_x else top
axis_end = right if split_x else bottom
split_at = min(axis_end - 1, max(axis_start + 1, axis_start + midpoint + 1))
first = mask.clone()
second = mask.clone()
if split_x:
first[:, split_at:] = False
second[:, :split_at] = False
else:
first[split_at:, :] = False
second[:split_at, :] = False
if not bool(first.any()) or not bool(second.any()):
raise ValueError("Unable to split an oversized semantic tile core.")
return first, second
def _merge_small_cores(
cores: tuple[_OwnershipCore, ...],
*,
max_width: int,
max_height: int,
) -> tuple[_OwnershipCore, ...]:
"""Greedily combine nearby cores when one bounded window can hold both."""
pending = list(cores)
minimum_area = max(1, (max_width * max_height) // 4)
merged = True
while merged:
merged = False
for index, core in enumerate(tuple(pending)):
if core.area >= minimum_area:
continue
candidate_index = _best_merge_candidate_index(
core,
pending,
excluded_index=index,
max_width=max_width,
max_height=max_height,
)
if candidate_index is None:
continue
candidate = pending[candidate_index]
pending[index] = _OwnershipCore(
mask=torch.logical_or(core.mask, candidate.mask),
bounds=_union_bounds(core.bounds, candidate.bounds),
area=core.area + candidate.area,
)
pending.pop(candidate_index)
merged = True
break
return tuple(pending)
def _best_merge_candidate_index(
core: _OwnershipCore,
candidates: list[_OwnershipCore],
*,
excluded_index: int,
max_width: int,
max_height: int,
) -> int | None:
"""Return a candidate whose combined bounds fit one ownership budget."""
eligible: list[tuple[int, int, int]] = []
for index, candidate in enumerate(candidates):
if index == excluded_index:
continue
bounds = _union_bounds(core.bounds, candidate.bounds)
left, top, right, bottom = bounds
width = right - left
height = bottom - top
if width > max_width or height > max_height:
continue
distance = _bounds_distance(core.bounds, candidate.bounds)
eligible.append((width * height, distance, index))
if not eligible:
return None
return min(eligible, key=lambda item: (item[0], item[1]))[2]
def _bounds_distance(
first: tuple[int, int, int, int],
second: tuple[int, int, int, int],
) -> int:
"""Return the axis-aligned gap between two mask bounding boxes."""
left, top, right, bottom = first
other_left, other_top, other_right, other_bottom = second
horizontal = max(0, other_left - right, left - other_right)
vertical = max(0, other_top - bottom, top - other_bottom)
return horizontal + vertical
def _union_bounds(
first: tuple[int, int, int, int],
second: tuple[int, int, int, int],
) -> tuple[int, int, int, int]:
"""Return the tight rectangle containing both ownership-core bounds."""
return (
min(first[0], second[0]),
min(first[1], second[1]),
max(first[2], second[2]),
max(first[3], second[3]),
)
def _tile_for_core(
core: _OwnershipCore,
*,
latent_width: int,
latent_height: int,
tile_width: int,
tile_height: int,
overlap: int,
) -> LatentTile:
"""Place one bounded sampling window around an irregular ownership core."""
left, top, right, bottom = core.bounds
center_x = (left + right) / 2.0
center_y = (top + bottom) / 2.0
x = _clamp_window_start(center_x, tile_width, latent_width)
y = _clamp_window_start(center_y, tile_height, latent_height)
weight_mask = _feathered_tile_weight(
core.mask,
x=x,
y=y,
width=tile_width,
height=tile_height,
overlap=overlap,
)
if not bool((weight_mask > 0).any()):
raise ValueError("Semantic tiled diffusion generated an empty tile weight.")
return LatentTile(x, y, tile_width, tile_height, weight_mask)
def _clamp_window_start(center: float, window_size: int, limit: int) -> int:
"""Center a fixed sampling window while keeping it inside latent bounds."""
desired = round(center - window_size / 2.0)
return min(max(0, desired), limit - window_size)
def _feathered_tile_weight(
mask: torch.Tensor,
*,
x: int,
y: int,
width: int,
height: int,
overlap: int,
) -> torch.Tensor:
"""Build one feathered tile weight without blurring the full latent mask."""
if overlap == 0:
return mask[y : y + height, x : x + width].float().contiguous()
radius = max(1, overlap // 2)
source_left = max(0, x - radius)
source_top = max(0, y - radius)
source_right = min(int(mask.shape[1]), x + width + radius)
source_bottom = min(int(mask.shape[0]), y + height + radius)
local_weight = (
functional.avg_pool2d(
mask[source_top:source_bottom, source_left:source_right]
.float()
.unsqueeze(0)
.unsqueeze(0),
kernel_size=radius * 2 + 1,
stride=1,
padding=radius,
count_include_pad=False,
)
.squeeze(0)
.squeeze(0)
)
local_y = y - source_top
local_x = x - source_left
return local_weight[
local_y : local_y + height, local_x : local_x + width
].contiguous()
def _core_from_mask(mask: torch.Tensor) -> _OwnershipCore:
"""Build one core with bounds and area computed exactly once."""
bounds = _mask_bounds(mask)
if bounds is None:
raise ValueError("Semantic tiled diffusion cannot use an empty core.")
return _OwnershipCore(mask=mask, bounds=bounds, area=int(mask.sum().item()))
def _mask_bounds(mask: torch.Tensor) -> tuple[int, int, int, int] | None:
"""Return left, top, right, bottom bounds for a non-empty boolean mask."""
y_coords, x_coords = torch.where(mask)
if y_coords.numel() == 0:
return None
return (
int(x_coords.min().item()),
int(y_coords.min().item()),
int(x_coords.max().item()) + 1,
int(y_coords.max().item()) + 1,
)
+164
View File
@@ -0,0 +1,164 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Define immutable spatial model views and view-major batch layouts."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class SpatialViewKind(StrEnum):
"""Identify how one model view relates to the canonical latent canvas."""
FULL = "full"
TILE = "tile"
CONTEXTUAL_GLOBAL = "contextual_global"
@dataclass(frozen=True, slots=True)
class SpatialView:
"""Describe one source rectangle evaluated at one model spatial shape."""
kind: SpatialViewKind
source_x: int
source_y: int
source_width: int
source_height: int
model_width: int
model_height: int
def __post_init__(self) -> None:
"""Reject invalid or internally inconsistent view geometry."""
if not isinstance(self.kind, SpatialViewKind):
raise TypeError("Spatial view kind must be a SpatialViewKind value.")
if self.source_x < 0 or self.source_y < 0:
raise ValueError("Spatial view source coordinates must be non-negative.")
if self.source_width < 1 or self.source_height < 1:
raise ValueError("Spatial view source dimensions must be positive.")
if self.model_width < 1 or self.model_height < 1:
raise ValueError("Spatial view model dimensions must be positive.")
if self.kind is SpatialViewKind.FULL and (
self.source_width != self.model_width
or self.source_height != self.model_height
):
raise ValueError("A full spatial view must preserve its source dimensions.")
@property
def source_right(self) -> int:
"""Return the exclusive source rectangle right edge."""
return self.source_x + self.source_width
@property
def source_bottom(self) -> int:
"""Return the exclusive source rectangle bottom edge."""
return self.source_y + self.source_height
@dataclass(frozen=True, slots=True)
class SpatialBatchLayout:
"""Describe ordered spatial views expanded over one source model batch."""
canvas_width: int
canvas_height: int
views: tuple[SpatialView, ...]
input_batch_size: int
def __post_init__(self) -> None:
"""Validate canvas containment and homogeneous model-call semantics."""
if self.canvas_width < 1 or self.canvas_height < 1:
raise ValueError("Spatial layout canvas dimensions must be positive.")
if self.input_batch_size < 1:
raise ValueError("Spatial layout input batch size must be positive.")
if not isinstance(self.views, tuple):
raise TypeError("Spatial layout views must be an immutable tuple.")
if not self.views:
raise ValueError("Spatial layout requires at least one view.")
if not all(isinstance(view, SpatialView) for view in self.views):
raise TypeError("Spatial layout views must contain SpatialView values.")
view_kind = self.views[0].kind
if any(view.kind is not view_kind for view in self.views):
raise ValueError("One spatial model call cannot mix view kinds.")
for view in self.views:
if (
view.source_right > self.canvas_width
or view.source_bottom > self.canvas_height
):
raise ValueError(
"Spatial view source rectangle must remain inside the canvas."
)
if view_kind in {
SpatialViewKind.FULL,
SpatialViewKind.CONTEXTUAL_GLOBAL,
}:
if len(self.views) != 1:
raise ValueError("A full-source spatial layout requires one view.")
view = self.views[0]
if (
view.source_x != 0
or view.source_y != 0
or view.source_width != self.canvas_width
or view.source_height != self.canvas_height
):
raise ValueError(
"A full-source spatial view must cover the complete canvas."
)
@property
def view_count(self) -> int:
"""Return the number of ordered spatial views."""
return len(self.views)
@property
def expanded_batch_size(self) -> int:
"""Return the model batch size after view-major expansion."""
return self.view_count * self.input_batch_size
@property
def expanded_views(self) -> tuple[SpatialView, ...]:
"""Repeat each view for its contiguous source-batch group."""
return tuple(
view
for view in self.views
for _source_batch_index in range(self.input_batch_size)
)
@property
def expanded_view_indices(self) -> tuple[int, ...]:
"""Return the view index for every expanded model-batch entry."""
return tuple(
view_index
for view_index in range(self.view_count)
for _source_batch_index in range(self.input_batch_size)
)
@property
def expanded_source_batch_indices(self) -> tuple[int, ...]:
"""Return the source-batch index for every expanded model-batch entry."""
return tuple(
source_batch_index
for _view in self.views
for source_batch_index in range(self.input_batch_size)
)
def expanded_index(self, view_index: int, source_batch_index: int) -> int:
"""Return one view-major model-batch index after validating both axes."""
if not 0 <= view_index < self.view_count:
raise IndexError("Spatial view index is outside the layout.")
if not 0 <= source_batch_index < self.input_batch_size:
raise IndexError("Source batch index is outside the layout.")
return view_index * self.input_batch_size + source_batch_index
+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__)
+6 -4
View File
@@ -20,12 +20,13 @@ TILED_DIFFUSION_MODES = ("multidiffusion", "mixture_of_diffusers")
@dataclass(frozen=True)
class LatentTile:
"""Describe one rectangular latent-space tile."""
"""Describe one rectangular latent-space tile and optional local blend weights."""
x: int
y: int
width: int
height: int
weight_mask: torch.Tensor | None = None
@property
def slicer(self) -> tuple[slice, slice, slice, slice]:
@@ -73,7 +74,8 @@ def build_tiled_diffusion_plan(
)
effective_tile_width = min(tile_width, latent_width)
effective_tile_height = min(tile_height, latent_height)
effective_overlap = max(0, min(overlap, min(tile_width, tile_height) - 4))
max_effective_overlap = min(effective_tile_width, effective_tile_height) - 4
effective_overlap = max(0, min(overlap, max_effective_overlap))
tiles = _split_tiles(
latent_width=latent_width,
@@ -82,7 +84,7 @@ def build_tiled_diffusion_plan(
tile_height=effective_tile_height,
overlap=effective_overlap,
)
batches, effective_tile_batch_size = _batch_tiles(tiles, tile_batch_size)
batches, effective_tile_batch_size = batch_latent_tiles(tiles, tile_batch_size)
return TiledDiffusionPlan(
latent_width=latent_width,
latent_height=latent_height,
@@ -213,7 +215,7 @@ def _split_tiles(
return tuple(tiles)
def _batch_tiles(
def batch_latent_tiles(
tiles: tuple[LatentTile, ...],
requested_tile_batch_size: int,
) -> tuple[tuple[tuple[LatentTile, ...], ...], int]:
+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."""
@@ -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
"""HTTP route registration for external LLM provider settings."""
from __future__ import annotations
import asyncio
import sys
from collections.abc import Callable, Coroutine
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
LOGGER = get_logger(__name__)
EXTERNAL_LLM_SETTINGS_ROUTE = "/simple-syrup/external-llm/settings"
EXTERNAL_LLM_API_KEY_ROUTE = "/simple-syrup/external-llm/api-key"
EXTERNAL_LLM_MODELS_REFRESH_ROUTE = "/simple-syrup/external-llm/models/refresh"
Handler = Callable[[Any], Coroutine[Any, Any, web.Response]]
_REGISTERED_PROMPT_SERVERS: set[int] = set()
class RoutesProtocol(Protocol):
"""Subset of Comfy's route table needed for route registration."""
def get(self, path: str) -> Callable[[Handler], Handler]:
"""Return a GET route decorator."""
def post(self, path: str) -> Callable[[Handler], Handler]:
"""Return a POST route decorator."""
def delete(self, path: str) -> Callable[[Handler], Handler]:
"""Return a DELETE route decorator."""
class PromptServerProtocol(Protocol):
"""Subset of Comfy's PromptServer needed for route registration."""
routes: RoutesProtocol
class ExternalLLMHandlers:
"""Handle SimpleSyrup external LLM HTTP requests."""
def __init__(self, service: ExternalLLMPromptService) -> None:
"""Create handlers backed by the external LLM service."""
self._service = service
async def get_settings(self, _request: Any) -> web.Response:
"""Return current non-secret external LLM settings."""
return web.json_response(self._settings_payload())
async def post_settings(self, request: Any) -> web.Response:
"""Validate and persist non-secret external LLM settings."""
try:
payload = await request.json()
if not isinstance(payload, dict):
raise ExternalLLMConfigError(
"External LLM settings request body must be a JSON object."
)
base_url = payload.get("base_url", "")
default_model = payload.get("default_model", "")
if not isinstance(base_url, str) or not isinstance(default_model, str):
raise ExternalLLMConfigError(
"External LLM settings require string base_url and "
"default_model values."
)
self._service.save_config(base_url, default_model)
except ExternalLLMConfigError as error:
return web.json_response({"error": str(error)}, status=400)
except Exception as error:
LOGGER.warning(
"invalid external llm settings request body",
extra={"route": EXTERNAL_LLM_SETTINGS_ROUTE, "reason": str(error)},
)
return web.json_response(
{"error": "External LLM settings request body must be valid JSON."},
status=400,
)
return web.json_response(self._settings_payload())
async def post_api_key(self, request: Any) -> web.Response:
"""Store an external LLM API key without returning it."""
try:
payload = await request.json()
if not isinstance(payload, dict):
raise ExternalLLMConfigError(
"External LLM API key request body must be a JSON object."
)
api_key = payload.get("api_key")
if not isinstance(api_key, str):
raise ExternalLLMConfigError(
"External LLM API key request requires an api_key string."
)
await asyncio.to_thread(self._service.save_api_key, api_key)
except (
ExternalLLMConfigError,
ExternalLLMKeyringError,
ExternalLLMProviderError,
) as error:
return web.json_response({"error": str(error)}, status=400)
except Exception as error:
LOGGER.warning(
"invalid external llm api key request body",
extra={"route": EXTERNAL_LLM_API_KEY_ROUTE, "reason": str(error)},
)
return web.json_response(
{"error": "External LLM API key request body must be valid JSON."},
status=400,
)
return web.json_response(self._settings_payload())
async def delete_api_key(self, _request: Any) -> web.Response:
"""Delete the configured external LLM API key."""
try:
self._service.delete_api_key()
except ExternalLLMKeyringError as error:
return web.json_response({"error": str(error)}, status=400)
return web.json_response(self._settings_payload())
async def refresh_models(self, _request: Any) -> web.Response:
"""Refresh cached external LLM model ids."""
try:
await asyncio.to_thread(self._service.refresh_models)
except (
ExternalLLMConfigError,
ExternalLLMKeyringError,
ExternalLLMProviderError,
) as error:
return web.json_response({"error": str(error)}, status=400)
return web.json_response(self._settings_payload())
def _settings_payload(self) -> dict[str, object]:
"""Return non-secret external LLM settings plus API key presence."""
return self._service.settings_payload()
def register_external_llm_routes(
service: ExternalLLMPromptService | None = None,
prompt_server: PromptServerProtocol | None = None,
) -> bool:
"""Register external LLM routes with Comfy's PromptServer."""
server_instance = prompt_server or _prompt_server_instance()
if server_instance is None:
return False
server_key = id(server_instance)
if prompt_server is None and server_key in _REGISTERED_PROMPT_SERVERS:
return True
handlers = ExternalLLMHandlers(service or ExternalLLMPromptService())
server_instance.routes.get(EXTERNAL_LLM_SETTINGS_ROUTE)(handlers.get_settings)
server_instance.routes.post(EXTERNAL_LLM_SETTINGS_ROUTE)(handlers.post_settings)
server_instance.routes.post(EXTERNAL_LLM_API_KEY_ROUTE)(handlers.post_api_key)
server_instance.routes.delete(EXTERNAL_LLM_API_KEY_ROUTE)(handlers.delete_api_key)
server_instance.routes.post(EXTERNAL_LLM_MODELS_REFRESH_ROUTE)(
handlers.refresh_models
)
if prompt_server is None:
_REGISTERED_PROMPT_SERVERS.add(server_key)
return True
def _prompt_server_instance() -> PromptServerProtocol | None:
"""Return Comfy's PromptServer instance when available."""
try:
server_module = sys.modules["server"]
prompt_server = server_module.PromptServer
instance = prompt_server.instance
except (KeyError, AttributeError) as error:
LOGGER.debug(
"PromptServer unavailable for external LLM routes",
extra={"reason": str(error)},
)
return None
return cast(PromptServerProtocol, instance)
@@ -0,0 +1,185 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""HTTP adapter for channel-accurate authored-mask previews."""
from __future__ import annotations
import sys
from collections.abc import Callable, Coroutine, Sequence
from importlib import import_module
from typing import Any, Protocol, cast
from aiohttp import web
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
MASK_BATCH_PREVIEW_ROUTE = "/simple-syrup/mask-batch/preview"
Handler = Callable[[Any], Coroutine[Any, Any, web.Response]]
_REGISTERED_PROMPT_SERVERS: set[int] = set()
class RoutesProtocol(Protocol):
"""Subset of Comfy's route table needed for preview registration."""
def post(self, path: str) -> Callable[[Handler], Handler]:
"""Return a POST route decorator."""
class PromptServerProtocol(Protocol):
"""Subset of Comfy's PromptServer needed for route registration."""
routes: RoutesProtocol
class MaskBatchLoaderProtocol(Protocol):
"""Load ordered masks with the same channel semantics as node execution."""
def load_each(self, files: Sequence[str], channel: str) -> Sequence[object]:
"""Load ordered files independently through the selected mask channel."""
class MaskBatchPreviewRendererProtocol(Protocol):
"""Render loaded masks into Comfy's native execution-output shape."""
def render(self, masks: Sequence[object]) -> dict[str, object]:
"""Return a JSON-compatible native preview payload."""
class NativeMaskBatchPreviewRenderer:
"""Render mask tensors through Comfy's authoritative PreviewMask helper."""
def render(self, masks: Sequence[object]) -> dict[str, object]:
"""Save one native temporary preview per independently sized mask."""
comfy_api: Any = import_module("comfy_api.latest")
images: list[object] = []
for mask in masks:
preview: Any = comfy_api.UI.PreviewMask(mask)
payload: object = preview.as_dict()
if not isinstance(payload, dict):
raise TypeError(
"Comfy PreviewMask returned an invalid preview payload."
)
preview_images = payload.get("images")
if not isinstance(preview_images, (list, tuple)):
raise TypeError("Comfy PreviewMask returned invalid preview images.")
images.extend(preview_images)
return {"images": images, "animated": (False,)}
class MaskBatchPreviewHandlers:
"""Handle previews using native mask semantics without batching constraints."""
def __init__(
self,
loader: MaskBatchLoaderProtocol,
renderer: MaskBatchPreviewRendererProtocol,
) -> None:
"""Create handlers with explicit loading and rendering collaborators."""
self._loader = loader
self._renderer = renderer
async def post_preview(self, request: Any) -> web.Response:
"""Return native previews for ordered files and one selected channel."""
try:
payload: object = await request.json()
except Exception as error:
LOGGER.warning(
"invalid mask batch preview request body",
extra={"route": MASK_BATCH_PREVIEW_ROUTE, "reason": str(error)},
)
return web.json_response(
{"error": ("Load Mask Batch preview request body must be valid JSON.")},
status=400,
)
try:
files, channel = self._request_values(payload)
masks = self._loader.load_each(files, channel)
except (TypeError, ValueError) as error:
return web.json_response({"error": str(error)}, status=400)
try:
preview = self._renderer.render(masks)
except Exception as error:
LOGGER.exception(
"mask batch preview rendering failed",
extra={"route": MASK_BATCH_PREVIEW_ROUTE, "reason": str(error)},
)
return web.json_response(
{"error": "ComfyUI could not render the mask batch preview."},
status=500,
)
return web.json_response(preview)
def _request_values(self, payload: object) -> tuple[tuple[str, ...], str]:
"""Validate and narrow a JSON preview request."""
if not isinstance(payload, dict):
raise TypeError("Load Mask Batch preview payload must be an object.")
files = payload.get("files")
if not isinstance(files, list):
raise TypeError("Load Mask Batch preview files must be a list.")
if not files:
raise ValueError("Load Mask Batch preview requires at least one mask file.")
if any(not isinstance(path, str) or not path for path in files):
raise TypeError("Load Mask Batch preview files must be non-empty strings.")
channel = payload.get("channel")
if not isinstance(channel, str) or not channel:
raise TypeError(
"Load Mask Batch preview channel must be a non-empty string."
)
return tuple(files), channel
def register_mask_batch_preview_routes(
loader: MaskBatchLoaderProtocol | None = None,
renderer: MaskBatchPreviewRendererProtocol | None = None,
prompt_server: PromptServerProtocol | None = None,
) -> bool:
"""Register mask preview routes with Comfy's PromptServer when available."""
server_instance = prompt_server or _prompt_server_instance()
if server_instance is None:
return False
server_key = id(server_instance)
if prompt_server is None and server_key in _REGISTERED_PROMPT_SERVERS:
return True
if loader is None:
from ..services.load_mask_batch_service import LoadMaskBatchService
loader = LoadMaskBatchService()
handlers = MaskBatchPreviewHandlers(
loader,
renderer or NativeMaskBatchPreviewRenderer(),
)
server_instance.routes.post(MASK_BATCH_PREVIEW_ROUTE)(handlers.post_preview)
if prompt_server is None:
_REGISTERED_PROMPT_SERVERS.add(server_key)
return True
def _prompt_server_instance() -> PromptServerProtocol | None:
"""Return Comfy's PromptServer instance without importing it eagerly."""
try:
server_module = sys.modules["server"]
prompt_server = server_module.PromptServer
instance = prompt_server.instance
except (KeyError, AttributeError) as error:
LOGGER.debug(
"PromptServer unavailable for mask batch preview routes",
extra={"reason": str(error)},
)
return None
return cast(PromptServerProtocol, instance)
@@ -0,0 +1,143 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Expose global quant cache status and safe inactive-artifact clearing."""
from __future__ import annotations
import sys
from collections.abc import Callable, Coroutine
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
QUANT_CACHE_ROUTE = "/simple-syrup/quant-cache"
Handler = Callable[[Any], Coroutine[Any, Any, web.Response]]
_REGISTERED_PROMPT_SERVERS: set[int] = set()
class QuantCacheRoutesProtocol(Protocol):
"""Describe the route decorators used by quant cache endpoints."""
def get(self, path: str) -> Callable[[Handler], Handler]:
"""Return a GET route decorator."""
def delete(self, path: str) -> Callable[[Handler], Handler]:
"""Return a DELETE route decorator."""
def post(self, path: str) -> Callable[[Handler], Handler]:
"""Return a POST route decorator."""
class QuantCachePromptServerProtocol(Protocol):
"""Describe the PromptServer state required by cache routes."""
routes: QuantCacheRoutesProtocol
class QuantCacheServiceBoundary(Protocol):
"""Describe global cache operations exposed through HTTP."""
def status(self) -> QuantCacheStatus:
"""Return current cache state."""
def clear_inactive(self) -> QuantCacheEvictionResult:
"""Remove every inactive managed artifact."""
def enforce_limit(self, limit_bytes: int) -> QuantCacheEvictionResult:
"""Apply the current global LRU budget."""
class QuantCacheHandlers:
"""Serve global quant cache state and explicit clear requests."""
def __init__(
self,
cache_service: QuantCacheServiceBoundary,
limit_provider: QuantCacheLimitProvider,
) -> None:
"""Create handlers with explicit authoritative collaborators."""
self._cache_service = cache_service
self._limit_provider = limit_provider
async def get_status(self, _request: Any) -> web.Response:
"""Return current global cache usage and configured limit."""
status = self._cache_service.status()
return web.json_response(status.to_payload(self._limit_provider.limit_bytes()))
async def clear_inactive(self, _request: Any) -> web.Response:
"""Clear inactive artifacts and return updated global cache status."""
eviction = self._cache_service.clear_inactive()
status = self._cache_service.status()
payload = status.to_payload(self._limit_provider.limit_bytes())
payload.update(
{
"removed_artifacts": eviction.removed_artifacts,
"removed_bytes": eviction.removed_bytes,
}
)
return web.json_response(payload)
async def enforce_limit(self, _request: Any) -> web.Response:
"""Apply the persisted limit and return updated global cache status."""
limit_bytes = self._limit_provider.limit_bytes()
eviction = self._cache_service.enforce_limit(limit_bytes)
status = self._cache_service.status()
payload = status.to_payload(limit_bytes)
payload.update(
{
"removed_artifacts": eviction.removed_artifacts,
"removed_bytes": eviction.removed_bytes,
}
)
return web.json_response(payload)
def register_quant_cache_routes(
cache_service: QuantCacheServiceBoundary | None = None,
limit_provider: QuantCacheLimitProvider | None = None,
prompt_server: QuantCachePromptServerProtocol | None = None,
) -> bool:
"""Register global cache routes with ComfyUI when PromptServer is available."""
server_instance = prompt_server or _prompt_server_instance()
if server_instance is None:
return False
server_key = id(server_instance)
if prompt_server is None and server_key in _REGISTERED_PROMPT_SERVERS:
return True
handlers = QuantCacheHandlers(
cache_service or QuantCacheService(),
limit_provider or SettingsQuantCacheLimitProvider(),
)
server_instance.routes.get(QUANT_CACHE_ROUTE)(handlers.get_status)
server_instance.routes.post(QUANT_CACHE_ROUTE)(handlers.enforce_limit)
server_instance.routes.delete(QUANT_CACHE_ROUTE)(handlers.clear_inactive)
if prompt_server is None:
_REGISTERED_PROMPT_SERVERS.add(server_key)
return True
def _prompt_server_instance() -> QuantCachePromptServerProtocol | None:
"""Return ComfyUI's PromptServer instance when available."""
try:
server_module = sys.modules["server"]
prompt_server = server_module.PromptServer
instance = prompt_server.instance
except (KeyError, AttributeError):
return None
return cast(QuantCachePromptServerProtocol, instance)
@@ -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,
SimpleSyrupSettingsRepository,
)
from ..runtime.settings_repository import SimpleSyrupSettingsRepository
from ..shared.logging import get_logger
LOGGER = get_logger(__name__)
SETTINGS_ROUTE = "/simple-syrup/settings"
@@ -60,7 +60,7 @@ class SettingsHandlers:
try:
payload = await request.json()
settings = SimpleSyrupSettings.from_payload(payload)
settings = self._settings_from_request_payload(payload)
except SimpleSyrupSettingsError as error:
return web.json_response({"error": str(error)}, status=400)
except Exception as error:
@@ -76,6 +76,27 @@ class SettingsHandlers:
saved = self._repository.save(settings)
return web.json_response(saved.to_payload())
def _settings_from_request_payload(self, payload: object) -> SimpleSyrupSettings:
"""Return validated settings while preserving omitted nested config."""
settings = SimpleSyrupSettings.from_payload(payload)
if not isinstance(payload, dict):
return settings
current = self._repository.load()
return SimpleSyrupSettings(
show_downloadable_models=settings.show_downloadable_models,
quant_cache_limit_gib=(
settings.quant_cache_limit_gib
if "quant_cache_limit_gib" in payload
else current.quant_cache_limit_gib
),
external_llm=(
settings.external_llm
if "external_llm" in payload
else current.external_llm
),
)
def register_settings_routes(
repository: SimpleSyrupSettingsRepository | None = None,
+52
View File
@@ -0,0 +1,52 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Extract deterministic connected components from binary mask regions."""
from __future__ import annotations
from dataclasses import dataclass
from importlib import import_module
import torch
from ..domain.segs import BoundingBox
@dataclass(frozen=True, slots=True)
class MaskComponent:
"""Represent one connected mask component and its full-image bbox."""
bbox: BoundingBox
mask: torch.Tensor
def connected_mask_components(active_mask: torch.Tensor) -> tuple[MaskComponent, ...]:
"""Return spatially ordered 8-connected components from one HW mask."""
if not isinstance(active_mask, torch.Tensor) or active_mask.ndim != 2:
raise ValueError("active_mask must be an HW tensor.")
active = active_mask.detach().to(device="cpu", dtype=torch.uint8).numpy()
if not active.any():
return ()
cv2 = import_module("cv2")
component_count, labels, stats, _centroids = cv2.connectedComponentsWithStats(
active,
connectivity=8,
)
components: list[MaskComponent] = []
for label in range(1, int(component_count)):
left = int(stats[label, cv2.CC_STAT_LEFT])
top = int(stats[label, cv2.CC_STAT_TOP])
width = int(stats[label, cv2.CC_STAT_WIDTH])
height = int(stats[label, cv2.CC_STAT_HEIGHT])
components.append(
MaskComponent(
bbox=BoundingBox(left, top, left + width, top + height),
mask=torch.from_numpy(labels == label),
)
)
return tuple(
sorted(components, key=lambda value: (value.bbox.top, value.bbox.left))
)
@@ -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,
@@ -0,0 +1,194 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Project canonical masks into exact regional activation multiplier shapes."""
from __future__ import annotations
from dataclasses import dataclass
import torch
from ..domain.regional_activation_geometry import (
RegionalActivationGeometry,
RegionalActivationLayout,
)
from ..domain.regional_mask_bank import RegionalMaskBank
from ..domain.spatial_views import (
SpatialBatchLayout,
SpatialView,
SpatialViewKind,
)
from .regional_mask_projection import (
RegionalMaskForm,
RegionalMaskProjectionMode,
RegionalMaskProjector,
)
@dataclass(frozen=True, slots=True)
class RegionalActivationMaskBatch:
"""Retain one finite region-major multiplier broadcast over rank activations."""
multipliers: torch.Tensor
geometry: RegionalActivationGeometry
def __post_init__(self) -> None:
"""Require exact geometry shape and bounded finite floating multipliers."""
if not isinstance(self.multipliers, torch.Tensor):
raise TypeError("Regional activation multipliers must be a tensor.")
if not isinstance(self.geometry, RegionalActivationGeometry):
raise TypeError("Regional activation multipliers require geometry.")
if not self.multipliers.is_floating_point():
raise TypeError("Regional activation multipliers must use floating point.")
if self.multipliers.ndim != len(self.geometry.invocation_shape) + 1:
raise ValueError("Regional activation multiplier rank is inconsistent.")
expected = self.geometry.broadcast_mask_shape(int(self.multipliers.shape[0]))
if tuple(self.multipliers.shape) != expected:
raise ValueError(
"Regional activation multiplier shape must match its geometry."
)
if not bool(torch.isfinite(self.multipliers).all()):
raise ValueError("Regional activation multipliers must be finite.")
if not bool(((self.multipliers >= 0.0) & (self.multipliers <= 1.0)).all()):
raise ValueError("Regional activation multipliers must stay within [0, 1].")
class RegionalActivationMaskProjector:
"""Own geometry-shaped mask projection around existing crop/interpolation."""
def __init__(self, projector: RegionalMaskProjector | None = None) -> None:
"""Retain the canonical mask crop and interpolation authority."""
self._projector = projector or RegionalMaskProjector()
def project(
self,
*,
bank: RegionalMaskBank,
geometry: RegionalActivationGeometry,
form: RegionalMaskForm,
mode: RegionalMaskProjectionMode,
device: torch.device,
dtype: torch.dtype,
) -> RegionalActivationMaskBatch:
"""Return masks in region/view/chunk/latent activation order."""
_validate_inputs(bank, geometry, form, mode, device, dtype)
layout = geometry.batch_alignment.spatial_layout or _full_layout(
bank,
input_batch_size=geometry.batch_alignment.base_batch_size,
)
projected_views = tuple(
self._projector.project_query_grid(
bank=bank,
layout=layout,
view_index=view_index,
query_height=geometry.spatial_height,
query_width=geometry.spatial_width,
form=form,
mode=mode,
)
for view_index in range(layout.view_count)
)
masks = torch.cat(
tuple(
projected.unsqueeze(1).expand(
-1,
layout.input_batch_size,
-1,
-1,
)
for projected in projected_views
),
dim=1,
).to(device=device, dtype=dtype)
multipliers = _reshape_for_activation(masks, geometry)
return RegionalActivationMaskBatch(multipliers, geometry)
def _reshape_for_activation(
masks: torch.Tensor,
geometry: RegionalActivationGeometry,
) -> torch.Tensor:
"""Place projected H/W masks on the declared operation's non-feature axes."""
regions, batch, height, width = (int(value) for value in masks.shape)
if geometry.layout is RegionalActivationLayout.DIRECT_CONVOLUTION_1D:
if height != 1:
raise ValueError("Conv1d regional masks require projected height one.")
return masks.reshape(regions, batch, 1, width)
if geometry.layout is RegionalActivationLayout.DIRECT_CONVOLUTION_2D:
return masks.reshape(regions, batch, 1, height, width)
if geometry.layout is RegionalActivationLayout.DIRECT_CONVOLUTION_3D:
temporal_size = geometry.temporal_size
if temporal_size is None:
raise ValueError("Conv3d regional masks require explicit temporal size.")
return masks.reshape(regions, batch, 1, 1, height, width).expand(
-1,
-1,
-1,
temporal_size,
-1,
-1,
)
if geometry.layout in (
RegionalActivationLayout.FLATTENED_SPATIAL_TOKENS,
RegionalActivationLayout.CONSUMER_SPATIALIZED,
):
return masks.flatten(start_dim=2).unsqueeze(-1)
raise AssertionError(f"Unhandled regional activation layout: {geometry.layout}")
def _full_layout(
bank: RegionalMaskBank,
*,
input_batch_size: int,
) -> SpatialBatchLayout:
"""Represent one full-canvas invocation through the shared layout contract."""
return SpatialBatchLayout(
bank.canvas_width,
bank.canvas_height,
(
SpatialView(
SpatialViewKind.FULL,
0,
0,
bank.canvas_width,
bank.canvas_height,
bank.canvas_width,
bank.canvas_height,
),
),
input_batch_size,
)
def _validate_inputs(
bank: object,
geometry: object,
form: object,
mode: object,
device: object,
dtype: object,
) -> None:
"""Validate typed projection inputs without moving or mutating source masks."""
if not isinstance(bank, RegionalMaskBank):
raise TypeError("Regional activation projection requires a mask bank.")
if not isinstance(geometry, RegionalActivationGeometry):
raise TypeError("Regional activation projection requires geometry.")
if not isinstance(form, RegionalMaskForm):
raise TypeError("Regional activation mask form has an invalid type.")
if not isinstance(mode, RegionalMaskProjectionMode):
raise TypeError("Regional activation projection mode has an invalid type.")
if not isinstance(device, torch.device):
raise TypeError("Regional activation mask device must be a torch.device.")
if not isinstance(dtype, torch.dtype) or not dtype.is_floating_point:
raise TypeError("Regional activation mask dtype must be floating point.")
REGIONAL_ACTIVATION_MASK_PROJECTOR = RegionalActivationMaskProjector()
@@ -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"
@@ -0,0 +1,125 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Classify active regional coverage on one projected query grid."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import torch
class RegionalCoverageClass(StrEnum):
"""Identify model branches required by one regional mask projection."""
ALL_BASE = "all_base"
ALL_SINGLE_REGION = "all_single_region"
MIXED = "mixed"
@dataclass(frozen=True, slots=True)
class RegionalMaskActivation:
"""Describe ordered active regions and an admitted coverage fast path."""
coverage_class: RegionalCoverageClass
active_region_indices: tuple[int, ...]
single_region_index: int | None
def __post_init__(self) -> None:
"""Require one internally consistent immutable classification."""
if not isinstance(self.coverage_class, RegionalCoverageClass):
raise TypeError(
"Regional coverage class must be a RegionalCoverageClass value."
)
if not isinstance(self.active_region_indices, tuple):
raise TypeError("Active regional indices must be an immutable tuple.")
if any(index < 0 for index in self.active_region_indices):
raise ValueError("Active regional indices must be non-negative.")
if tuple(sorted(set(self.active_region_indices))) != self.active_region_indices:
raise ValueError("Active regional indices must be unique and ordered.")
if self.coverage_class is RegionalCoverageClass.ALL_BASE:
if self.active_region_indices or self.single_region_index is not None:
raise ValueError("All-base coverage cannot contain an active region.")
return
if self.coverage_class is RegionalCoverageClass.ALL_SINGLE_REGION:
if (
len(self.active_region_indices) != 1
or self.single_region_index != self.active_region_indices[0]
):
raise ValueError(
"All-single-region coverage requires its one active region index."
)
return
if not self.active_region_indices:
raise ValueError("Mixed regional coverage requires an active region.")
if self.single_region_index is not None:
raise ValueError(
"Mixed regional coverage cannot select a fast-path region."
)
class RegionalMaskActivationClassifier:
"""Own zero-coverage pruning and regional fast-path admission."""
def classify(
self,
masks: torch.Tensor,
*,
tolerance: float = 1e-6,
) -> RegionalMaskActivation:
"""Return ordered active regions and the exact coverage classification."""
self._validate_masks(masks)
if not 0.0 <= tolerance < 0.5:
raise ValueError(
"Regional activation tolerance must be at least 0 and below 0.5."
)
active_region_indices = tuple(
index
for index in range(int(masks.shape[0]))
if bool((masks[index] > tolerance).any())
)
if not active_region_indices:
return RegionalMaskActivation(
coverage_class=RegionalCoverageClass.ALL_BASE,
active_region_indices=(),
single_region_index=None,
)
if len(active_region_indices) == 1:
region_index = active_region_indices[0]
if bool((masks[region_index] >= 1.0 - tolerance).all()):
return RegionalMaskActivation(
coverage_class=RegionalCoverageClass.ALL_SINGLE_REGION,
active_region_indices=active_region_indices,
single_region_index=region_index,
)
return RegionalMaskActivation(
coverage_class=RegionalCoverageClass.MIXED,
active_region_indices=active_region_indices,
single_region_index=None,
)
@staticmethod
def _validate_masks(masks: torch.Tensor) -> None:
"""Validate a projected normalized floating-point BHW mask batch."""
if not isinstance(masks, torch.Tensor):
raise TypeError("Regional activation masks must be a torch.Tensor.")
if masks.ndim != 3:
raise ValueError("Regional activation masks must use BHW layout.")
if int(masks.shape[0]) < 1:
raise ValueError("Regional activation requires at least one region.")
if int(masks.shape[1]) < 1 or int(masks.shape[2]) < 1:
raise ValueError("Regional activation mask grids must be non-empty.")
if not masks.is_floating_point():
raise TypeError(
"Regional activation masks must use a floating-point dtype."
)
if not bool(torch.isfinite(masks).all()):
raise ValueError("Regional activation masks must contain finite values.")
if not bool(((masks >= 0.0) & (masks <= 1.0)).all()):
raise ValueError("Regional activation masks must stay within [0, 1].")
@@ -0,0 +1,176 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Project canonical regional masks into spatial views and query grids."""
from __future__ import annotations
from enum import StrEnum
import torch
import torch.nn.functional as functional
from ..domain.regional_mask_bank import RegionalMaskBank
from ..domain.spatial_views import SpatialBatchLayout, SpatialView
class RegionalMaskForm(StrEnum):
"""Select one canonical regional mask representation."""
PLANNING = "planning"
CONDITIONING = "conditioning"
class RegionalMaskProjectionMode(StrEnum):
"""Select the interpolation semantics for one mask projection."""
CONTINUOUS_COVERAGE = "continuous_coverage"
SOFT = "soft"
HARD_PRESERVING = "hard_preserving"
NEAREST = "nearest"
class RegionalMaskProjector:
"""Own canonical-canvas crop and interpolation policy for regional masks."""
def project_view(
self,
*,
bank: RegionalMaskBank,
layout: SpatialBatchLayout,
view_index: int,
form: RegionalMaskForm,
mode: RegionalMaskProjectionMode,
) -> torch.Tensor:
"""Project one canonical mask form to a view's model dimensions."""
view = self._view(bank=bank, layout=layout, view_index=view_index)
return self.project_query_grid(
bank=bank,
layout=layout,
view_index=view_index,
query_height=view.model_height,
query_width=view.model_width,
form=form,
mode=mode,
)
def project_query_grid(
self,
*,
bank: RegionalMaskBank,
layout: SpatialBatchLayout,
view_index: int,
query_height: int,
query_width: int,
form: RegionalMaskForm,
mode: RegionalMaskProjectionMode,
) -> torch.Tensor:
"""Crop one spatial view and resample it directly to an attention grid."""
if query_height < 1 or query_width < 1:
raise ValueError("Regional mask query-grid dimensions must be positive.")
if not isinstance(form, RegionalMaskForm):
raise TypeError("Regional mask form must be a RegionalMaskForm value.")
if not isinstance(mode, RegionalMaskProjectionMode):
raise TypeError(
"Regional mask projection mode must be a "
"RegionalMaskProjectionMode value."
)
view = self._view(bank=bank, layout=layout, view_index=view_index)
masks = (
bank.planning_masks
if form is RegionalMaskForm.PLANNING
else bank.conditioning_masks
)
cropped = masks[
:,
view.source_y : view.source_bottom,
view.source_x : view.source_right,
]
projected = self._resize(
cropped,
height=query_height,
width=query_width,
mode=mode,
).clamp(0.0, 1.0)
if not bool(torch.isfinite(projected).all()):
raise ValueError("Projected regional masks contain non-finite values.")
return projected
@staticmethod
def _view(
*,
bank: RegionalMaskBank,
layout: SpatialBatchLayout,
view_index: int,
) -> SpatialView:
"""Validate canonical canvas identity and return one indexed view."""
if (
bank.canvas_width != layout.canvas_width
or bank.canvas_height != layout.canvas_height
):
raise ValueError(
"Regional mask bank canvas must match the spatial batch layout."
)
if view_index < 0 or view_index >= layout.view_count:
raise IndexError("Regional mask spatial view index is outside the layout.")
return layout.views[view_index]
@staticmethod
def _resize(
masks: torch.Tensor,
*,
height: int,
width: int,
mode: RegionalMaskProjectionMode,
) -> torch.Tensor:
"""Apply explicit coverage, soft, or hard-preserving interpolation."""
source_height = int(masks.shape[-2])
source_width = int(masks.shape[-1])
if (source_height, source_width) == (height, width):
return masks
batched = masks.unsqueeze(1)
if mode is RegionalMaskProjectionMode.HARD_PRESERVING:
return functional.interpolate(
batched,
size=(height, width),
mode="nearest-exact",
).squeeze(1)
if mode is RegionalMaskProjectionMode.NEAREST:
return functional.interpolate(
batched,
size=(height, width),
mode="nearest",
).squeeze(1)
if mode is RegionalMaskProjectionMode.SOFT:
return functional.interpolate(
batched,
size=(height, width),
mode="bilinear",
align_corners=False,
).squeeze(1)
downsample_height = min(source_height, height)
downsample_width = min(source_width, width)
projected = batched
if (downsample_height, downsample_width) != (
source_height,
source_width,
):
projected = functional.interpolate(
projected,
size=(downsample_height, downsample_width),
mode="area",
)
if tuple(projected.shape[-2:]) != (height, width):
projected = functional.interpolate(
projected,
size=(height, width),
mode="bilinear",
align_corners=False,
)
return projected.squeeze(1)
@@ -0,0 +1,118 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Mask preparation for full-context regional prompting."""
from __future__ import annotations
import torch
import torch.nn.functional as functional
from ..domain.regional_mask_bank import RegionalMaskBank
from .detailer_masks import gaussian_feather_mask
def prepare_regional_mask_batch(mask: object, feather: int) -> torch.Tensor:
"""Return a validated and optionally feathered BHW mask batch."""
_, conditioning = _prepare_regional_masks(mask, feather)
return conditioning
def build_regional_mask_bank(
mask: object,
*,
feather: int,
canvas_height: int,
canvas_width: int,
) -> RegionalMaskBank:
"""Build separate planning and conditioning masks on one latent canvas."""
authored, feathered = _prepare_regional_masks(mask, feather)
planning_masks = resize_regional_mask_batch(
authored,
height=canvas_height,
width=canvas_width,
)
conditioning_masks = resize_regional_mask_batch(
feathered,
height=canvas_height,
width=canvas_width,
)
return RegionalMaskBank(
planning_masks=planning_masks,
conditioning_masks=conditioning_masks,
canvas_width=canvas_width,
canvas_height=canvas_height,
)
def _prepare_regional_masks(
mask: object,
feather: int,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Return normalized authored masks and a separate feathered tensor."""
if not isinstance(mask, torch.Tensor):
raise TypeError("regional prompting requires a torch MASK tensor.")
if feather < 0:
raise ValueError("region_mask_feather must be greater than or equal to 0.")
working = mask.float()
if working.ndim == 2:
working = working.unsqueeze(0)
if working.ndim != 3:
raise ValueError("regional prompting requires an HW or BHW MASK tensor.")
if int(working.shape[0]) < 1:
raise ValueError("regional prompting requires at least one authored mask.")
if int(working.shape[1]) < 1 or int(working.shape[2]) < 1:
raise ValueError("regional masks must have non-empty height and width.")
normalized = working.clamp(0.0, 1.0)
conditioning = (
normalized.to(device=normalized.device, dtype=normalized.dtype, copy=True)
if feather == 0
else gaussian_feather_mask(normalized, feather)
)
return normalized, conditioning
def resize_regional_mask_batch(
mask_batch: torch.Tensor,
*,
height: int,
width: int,
) -> torch.Tensor:
"""Resize BHW masks while preserving authored area during downscaling."""
if height < 1 or width < 1:
raise ValueError("regional mask target height and width must be positive.")
if tuple(mask_batch.shape[1:]) == (height, width):
return mask_batch
source_height, source_width = map(int, mask_batch.shape[1:])
downscaled_height = min(height, source_height)
downscaled_width = min(width, source_width)
working = mask_batch.unsqueeze(1)
if (downscaled_height, downscaled_width) != (source_height, source_width):
working = functional.interpolate(
working,
size=(downscaled_height, downscaled_width),
mode="area",
)
if (downscaled_height, downscaled_width) != (height, width):
working = functional.interpolate(
working,
size=(height, width),
mode="bilinear",
align_corners=False,
)
return working.squeeze(1)
def regional_mask(mask_batch: torch.Tensor, index: int) -> torch.Tensor:
"""Return one positional region as a singleton BHW mask."""
if index < 0 or index >= int(mask_batch.shape[0]):
raise IndexError(f"regional mask index {index} is out of range.")
return mask_batch[index : index + 1]
+3 -132
View File
@@ -2,136 +2,7 @@
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""ComfyUI node registration for SimpleSyrup."""
"""Implementation modules for SimpleSyrup node behavior.
from __future__ import annotations
from .conditioning_batch_pack import ConditioningBatchAppend, ConditioningBatchStart
from .detail_segs_as_regions import DetailSEGSAsRegions
from .detail_segs_by_scale_factor import DetailSEGSByScaleFactor
from .detail_segs_by_scale_factor_tiled_diffusion import (
DetailSEGSByScaleFactorTiledDiffusion,
)
from .detect_segs_with_ultralytics import DetectSEGSWithUltralytics
from .encode_prompt_batch import EncodePromptBatch
from .grounded_sam_model_info import GroundedSAMModelInfo
from .grounding_dino_model_loader import GroundingDINOModelLoader
from .image_resize_to_target import ResizeImageToTarget
from .ksampler_extras import KSamplerExtras
from .ksampler_tiled_diffusion import KSamplerTiledDiffusion
from .latent_diagnostics import LatentDiagnostics
from .layerstyle_sam_models_adapter import LayerStyleSAMModelsAdapter
from .load_ultralytics_model import LoadUltralyticsModel
from .prompt_encode_style import PromptEncodeStyle
from .prompt_encode_style_and_normalization import PromptEncodeStyleAndNormalization
from .prompt_segs_with_sam import PromptSEGSWithSAM
from .provenance_latent import SimpleVAEEncode, UpscaleLatentFromImage
from .sam_model_loader import SAMModelLoader
from .scale_factor import ScaleFactor
from .seed import Seed
from .simple_load_anima import SimpleLoadAnima
from .simple_load_checkpoint import SimpleLoadCheckpoint
from .tile_and_tag_segs import TileAndTagSEGS
from .vitmatte_model_loader import ViTMatteModelLoader
from .wd14_tagger_loader import WD14TaggerLoader
NODE_CLASS_MAPPINGS = {
"SimpleSyrup.ConditioningBatchAppend": ConditioningBatchAppend,
"SimpleSyrup.ConditioningBatchStart": ConditioningBatchStart,
"SimpleSyrup.GroundedSAMModelInfo": GroundedSAMModelInfo,
"SimpleSyrup.GroundingDINOModelLoader": GroundingDINOModelLoader,
"SimpleSyrup.KSamplerExtras": KSamplerExtras,
"SimpleSyrup.KSamplerTiledDiffusion": KSamplerTiledDiffusion,
"SimpleSyrup.LayerStyleSAMModelsAdapter": LayerStyleSAMModelsAdapter,
"SimpleSyrup.LatentDiagnostics": LatentDiagnostics,
"SimpleSyrup.PromptEncodeStyle": PromptEncodeStyle,
"SimpleSyrup.PromptEncodeStyleAndNormalization": PromptEncodeStyleAndNormalization,
"SimpleSyrup.PromptSEGSWithSAM": PromptSEGSWithSAM,
"SimpleSyrup.SimpleVAEEncode": SimpleVAEEncode,
"SimpleSyrup.UpscaleLatentFromImage": UpscaleLatentFromImage,
"SimpleSyrup.ResizeImageToTarget": ResizeImageToTarget,
"SimpleSyrup.DetailSEGSAsRegions": DetailSEGSAsRegions,
"SimpleSyrup.DetailSEGSByScaleFactor": DetailSEGSByScaleFactor,
"SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion": (
DetailSEGSByScaleFactorTiledDiffusion
),
"SimpleSyrup.SAMModelLoader": SAMModelLoader,
"SimpleSyrup.ScaleFactor": ScaleFactor,
"SimpleSyrup.Seed": Seed,
"SimpleSyrup.SimpleLoadAnima": SimpleLoadAnima,
"SimpleSyrup.SimpleLoadCheckpoint": SimpleLoadCheckpoint,
"SimpleSyrup.LoadUltralyticsModel": LoadUltralyticsModel,
"SimpleSyrup.DetectSEGSWithUltralytics": DetectSEGSWithUltralytics,
"SimpleSyrup.EncodePromptBatch": EncodePromptBatch,
"SimpleSyrup.TileAndTagSEGS": TileAndTagSEGS,
"SimpleSyrup.ViTMatteModelLoader": ViTMatteModelLoader,
"SimpleSyrup.WD14TaggerLoader": WD14TaggerLoader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SimpleSyrup.ConditioningBatchAppend": "Conditioning Batch Append",
"SimpleSyrup.ConditioningBatchStart": "Conditioning Batch Start",
"SimpleSyrup.GroundedSAMModelInfo": "Grounded SAM Model Info",
"SimpleSyrup.GroundingDINOModelLoader": "GroundingDINO Model Loader",
"SimpleSyrup.KSamplerExtras": "KSampler (Extras)",
"SimpleSyrup.KSamplerTiledDiffusion": "KSampler (Tiled Diffusion)",
"SimpleSyrup.LayerStyleSAMModelsAdapter": "LayerStyle SAM Models Adapter",
"SimpleSyrup.LatentDiagnostics": "Latent Diagnostics",
"SimpleSyrup.PromptEncodeStyle": "Prompt Encode Style",
"SimpleSyrup.PromptEncodeStyleAndNormalization": (
"Prompt Encode Style & Normalization"
),
"SimpleSyrup.PromptSEGSWithSAM": "Prompt SEGS w/ SAM",
"SimpleSyrup.SimpleVAEEncode": "Simple VAE Encode",
"SimpleSyrup.UpscaleLatentFromImage": "Upscale Latent From Image",
"SimpleSyrup.ResizeImageToTarget": "Resize Image to Target",
"SimpleSyrup.DetailSEGSAsRegions": "Detail SEGS as Regions",
"SimpleSyrup.DetailSEGSByScaleFactor": "Detail SEGS by Scale Factor",
"SimpleSyrup.DetailSEGSByScaleFactorTiledDiffusion": (
"Detail SEGS by Scale Factor w/ Tiled Diffusion"
),
"SimpleSyrup.SAMModelLoader": "SAM Model Loader",
"SimpleSyrup.ScaleFactor": "Scale Factor",
"SimpleSyrup.Seed": "Seed",
"SimpleSyrup.SimpleLoadAnima": "Simple Load Anima",
"SimpleSyrup.SimpleLoadCheckpoint": "Simple Load Checkpoint",
"SimpleSyrup.LoadUltralyticsModel": "Load Ultralytics Model",
"SimpleSyrup.DetectSEGSWithUltralytics": "Detect SEGS w/ Ultralytics",
"SimpleSyrup.EncodePromptBatch": "Encode Prompt Batch",
"SimpleSyrup.TileAndTagSEGS": "Tile & Tag SEGS",
"SimpleSyrup.ViTMatteModelLoader": "ViTMatte Model Loader",
"SimpleSyrup.WD14TaggerLoader": "Load WD14 Tagger",
}
__all__ = [
"ConditioningBatchAppend",
"ConditioningBatchStart",
"GroundedSAMModelInfo",
"GroundingDINOModelLoader",
"KSamplerExtras",
"KSamplerTiledDiffusion",
"LayerStyleSAMModelsAdapter",
"LatentDiagnostics",
"DetectSEGSWithUltralytics",
"DetailSEGSAsRegions",
"DetailSEGSByScaleFactor",
"DetailSEGSByScaleFactorTiledDiffusion",
"EncodePromptBatch",
"LoadUltralyticsModel",
"NODE_CLASS_MAPPINGS",
"NODE_DISPLAY_NAME_MAPPINGS",
"PromptEncodeStyle",
"PromptEncodeStyleAndNormalization",
"PromptSEGSWithSAM",
"ResizeImageToTarget",
"SAMModelLoader",
"ScaleFactor",
"Seed",
"SimpleLoadAnima",
"SimpleLoadCheckpoint",
"SimpleVAEEncode",
"TileAndTagSEGS",
"UpscaleLatentFromImage",
"ViTMatteModelLoader",
"WD14TaggerLoader",
]
ComfyUI registration is v3-only and lives in `simple_syrup.nodes_v3`.
"""
@@ -0,0 +1,54 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""ComfyUI node declaration for batching regional conditioning."""
from __future__ import annotations
from typing import Any
from ..domain.conditioning_batch import batch_conditioning
from ..nodes import tooltips
class BatchRegionConditioning:
"""Combine conditioning and conditioning batches for regional detailing."""
RETURN_TYPES = ("CONDITIONING_BATCH",)
RETURN_NAMES = ("batch",)
OUTPUT_TOOLTIPS = (tooltips.BATCH_REGION_CONDITIONING_OUTPUT,)
FUNCTION = "batch"
CATEGORY = "SimpleSyrup/Conditioning"
DESCRIPTION = (
"Combines two CONDITIONING or CONDITIONING_BATCH inputs into one ordered "
"regional conditioning batch. Chain this node to batch more sources."
)
SEARCH_ALIASES = [
"batch",
"conditioning batch",
"region conditioning",
"segs prompts",
]
@classmethod
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
"""Declare legacy ComfyUI inputs for regional conditioning batching."""
return {
"required": {
"first": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.BATCH_REGION_CONDITIONING_FIRST},
),
"second": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.BATCH_REGION_CONDITIONING_SECOND},
),
},
}
def batch(self, first: Any, second: Any) -> tuple[object]:
"""Batch two conditioning inputs in input order."""
return (batch_conditioning((first, second)),)
+44
View File
@@ -0,0 +1,44 @@
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
# Copyright (C) 2026 Artificial Sweetener and contributors
# SPDX-License-Identifier: AGPL-3.0-or-later
"""ComfyUI node declaration for batching SEGS."""
from __future__ import annotations
from typing import Any
from ..domain.segs import batch_segs, to_impact_compatible_segs
from ..nodes import tooltips
class BatchSEGS:
"""Combine two SEGS payloads into one ordered SEGS payload."""
RETURN_TYPES = ("SEGS",)
RETURN_NAMES = ("segs",)
OUTPUT_TOOLTIPS = (tooltips.BATCH_SEGS_OUTPUT,)
FUNCTION = "batch"
CATEGORY = "SimpleSyrup/Detection"
DESCRIPTION = (
"Combines two SEGS inputs into one ordered SEGS payload. Chain this node "
"to batch more than two SEGS sources."
)
SEARCH_ALIASES = ["batch", "merge", "join", "combine", "segs"]
@classmethod
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
"""Declare legacy ComfyUI inputs for SEGS batching."""
return {
"required": {
"first": ("SEGS", {"tooltip": tooltips.BATCH_SEGS_FIRST}),
"second": ("SEGS", {"tooltip": tooltips.BATCH_SEGS_SECOND}),
},
}
def batch(self, first: object, second: object) -> tuple[object]:
"""Batch two SEGS payloads in input order."""
native = batch_segs((first, second))
return (to_impact_compatible_segs(native),)
+28 -23
View File
@@ -10,6 +10,7 @@ from typing import Any, ClassVar
import torch
from ..domain.noise_inversion import NoiseInversionOptions
from ..domain.segs import coerce_segs_group
from ..nodes import tooltips
from ..nodes.detailer_input_adapters import (
@@ -64,10 +65,6 @@ class DetailSEGSAsRegions:
"image": ("IMAGE", {"tooltip": tooltips.DETAIL_IMAGE}),
"model": ("MODEL", {"tooltip": tooltips.DETAIL_MODEL}),
"vae": ("VAE", {"tooltip": tooltips.DETAIL_VAE}),
"negative": (
"CONDITIONING",
{"tooltip": tooltips.REGIONAL_GLOBAL_NEGATIVE},
),
"positive": (
"CONDITIONING",
{"tooltip": tooltips.REGIONAL_GLOBAL_POSITIVE},
@@ -187,7 +184,13 @@ class DetailSEGSAsRegions:
"tooltip": tooltips.DETAIL_TILED_DECODE,
},
),
}
},
"optional": {
"negative": (
"CONDITIONING",
{"tooltip": tooltips.REGIONAL_GLOBAL_NEGATIVE},
),
},
}
def detail(
@@ -195,24 +198,25 @@ class DetailSEGSAsRegions:
image: object,
model: Any,
vae: Any,
negative: Any,
positive: Any,
segs: object,
region_positive: object,
global_prompt_weight: object,
scale_factor: object,
upscale_method: object,
seed: object,
steps: object,
cfg: object,
sampler_name: object,
scheduler: object,
denoise: object,
feather: object,
noise_mask: object,
noise_mask_feather: object,
tiled_encode: object,
tiled_decode: object,
negative: Any | None = None,
positive: Any = None,
segs: object = None,
region_positive: object = None,
global_prompt_weight: object = 0.25,
scale_factor: object = 1.0,
upscale_method: object = "lanczos",
seed: object = 0,
steps: object = 20,
cfg: object = 8.0,
sampler_name: object = "euler",
scheduler: object = "normal",
denoise: object = 0.5,
feather: object = 5,
noise_mask: object = True,
noise_mask_feather: object = 20,
tiled_encode: object = False,
tiled_decode: object = False,
noise_inversion: NoiseInversionOptions | None = None,
) -> tuple[object]:
"""Run regional detailing and return the detailed image."""
@@ -235,6 +239,7 @@ class DetailSEGSAsRegions:
strict=True,
):
result = service.detail(
noise_inversion=noise_inversion,
image=single_image,
segs=single_segs,
model=single_input(model, "model", list_mode, OPERATION),
@@ -61,10 +61,6 @@ class DetailSEGSByScaleFactor:
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_POSITIVE},
),
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
"scale_factor": (
"FLOAT",
scale_factor_options(default=1.5),
@@ -171,7 +167,13 @@ class DetailSEGSByScaleFactor:
"tooltip": tooltips.DETAIL_TILED_DECODE,
},
),
}
},
"optional": {
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
},
}
def detail(
@@ -181,21 +183,21 @@ class DetailSEGSByScaleFactor:
model: Any,
vae: Any,
positive: Any,
negative: Any,
scale_factor: object,
upscale_method: object,
clamp_size: object,
seed: object,
steps: object,
cfg: object,
sampler_name: object,
scheduler: object,
denoise: object,
feather: object,
noise_mask: object,
noise_mask_feather: object,
tiled_encode: object,
tiled_decode: object,
negative: Any | None = None,
scale_factor: object = 1.5,
upscale_method: object = "lanczos",
clamp_size: object = 0,
seed: object = 0,
steps: object = 20,
cfg: object = 8.0,
sampler_name: object = "euler",
scheduler: object = "normal",
denoise: object = 0.5,
feather: object = 5,
noise_mask: object = True,
noise_mask_feather: object = 20,
tiled_encode: object = False,
tiled_decode: object = False,
) -> tuple[object]:
"""Run scale-factor detailing and return the detailed image."""
@@ -10,6 +10,7 @@ from typing import Any, ClassVar
import torch
from ..domain.noise_inversion import NoiseInversionOptions
from ..domain.segs import coerce_segs_group
from ..domain.tiled_diffusion import TILED_DIFFUSION_MODES
from ..nodes import tooltips
@@ -70,10 +71,6 @@ class DetailSEGSByScaleFactorTiledDiffusion:
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_POSITIVE},
),
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
"scale_factor": (
"FLOAT",
scale_factor_options(default=1.5),
@@ -231,7 +228,13 @@ class DetailSEGSByScaleFactorTiledDiffusion:
"tooltip": tooltips.LATENT_TILE_BATCH_SIZE,
},
),
}
},
"optional": {
"negative": (
"CONDITIONING,CONDITIONING_BATCH",
{"tooltip": tooltips.DETAIL_NEGATIVE},
),
},
}
def detail(
@@ -241,26 +244,27 @@ class DetailSEGSByScaleFactorTiledDiffusion:
model: Any,
vae: Any,
positive: Any,
negative: Any,
scale_factor: object,
upscale_method: object,
clamp_size: object,
seed: object,
steps: object,
cfg: object,
sampler_name: object,
scheduler: object,
denoise: object,
feather: object,
noise_mask: object,
noise_mask_feather: object,
tiled_encode: object,
tiled_decode: object,
diffusion_mode: object,
latent_tile_width: object,
latent_tile_height: object,
latent_tile_overlap: object,
latent_tile_batch_size: object,
negative: Any | None = None,
scale_factor: object = 1.5,
upscale_method: object = "lanczos",
clamp_size: object = 0,
seed: object = 0,
steps: object = 20,
cfg: object = 8.0,
sampler_name: object = "euler",
scheduler: object = "normal",
denoise: object = 0.5,
feather: object = 5,
noise_mask: object = True,
noise_mask_feather: object = 20,
tiled_encode: object = False,
tiled_decode: object = False,
diffusion_mode: object = "multidiffusion",
latent_tile_width: object = 128,
latent_tile_height: object = 128,
latent_tile_overlap: object = 16,
latent_tile_batch_size: object = 4,
noise_inversion: NoiseInversionOptions | None = None,
) -> tuple[object]:
"""Run tiled diffusion scale-factor detailing and return the image."""
@@ -273,6 +277,7 @@ class DetailSEGSByScaleFactorTiledDiffusion:
outputs: list[torch.Tensor] = []
for single_image, single_segs in zip(images, segs_group, strict=True):
result = service.detail(
noise_inversion=noise_inversion,
image=single_image,
segs=single_segs,
model=single_input(model, "model", list_mode, OPERATION),
@@ -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, ...]:
@@ -15,18 +15,16 @@ from ..domain.segs import (
KEEP_BY_OPTIONS,
SORT_ORDER_OPTIONS,
NativeSegs,
limit_segs,
sort_segs,
to_impact_compatible_segs,
)
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,
)
from ..services.segs_output_service import (
CombinedSegsResult,
build_combined_segs_result,
finalize_detector_segs_output,
)
@@ -231,11 +229,17 @@ class DetectSEGSWithUltralytics:
sub_dilation=sub_dilation,
post_dilation=post_dilation,
)
segs = limit_segs(segs, keep_only, keep_by)
segs = sort_segs(segs, sort_order)
combined = type(self).combined_builder(single_image, segs, crop_factor)
output_segs = combined.segs if combine_segs else segs
segs_outputs.append(to_impact_compatible_segs(output_segs))
mask_outputs.append(combined.mask)
finalized = finalize_detector_segs_output(
image=single_image,
segs=segs,
keep_only=keep_only,
keep_by=keep_by,
crop_factor=crop_factor,
sort_order=sort_order,
combine_segs=combine_segs,
combined_builder=type(self).combined_builder,
)
segs_outputs.append(finalized.segs)
mask_outputs.append(finalized.mask)
return segs_outputs, torch.cat(mask_outputs, dim=0)
+27 -14
View File
@@ -8,8 +8,9 @@ from __future__ import annotations
from typing import Any, ClassVar
from ..domain.conditioning_batch import split_prompt_batch
from ..domain.prompt_batch_parser import DEFAULT_PROMPT_BATCH_SEPARATOR
from ..runtime.conditioning_encoding import ComfyConditioningEncoder
from ..services.prompt_batch_encoding_service import PromptBatchEncodingService
class EncodePromptBatch:
@@ -18,17 +19,22 @@ class EncodePromptBatch:
RETURN_TYPES = ("CONDITIONING_BATCH", "CONDITIONING_BATCH")
RETURN_NAMES = ("positive", "negative")
OUTPUT_TOOLTIPS = (
"Positive conditioning entries selected by SEGS order.",
"Negative conditioning entries selected by SEGS order.",
"Ordered positive conditioning entries for batch-aware consumers.",
"Ordered negative conditioning entries for batch-aware consumers.",
)
FUNCTION = "encode"
CATEGORY = "SimpleSyrup/Conditioning"
DESCRIPTION = (
"Encodes [SEP]-separated prompts into per-segment conditioning batches."
"Encodes prompts separated by [SEP] or [SEP|name] into matched "
"conditioning batches, reusing each side's global prompt when a "
"regional entry is missing."
)
SEARCH_ALIASES = ["conditioning batch", "prompt batch", "segs prompts"]
encoder_class: ClassVar[type[ComfyConditioningEncoder]] = ComfyConditioningEncoder
service_class: ClassVar[type[PromptBatchEncodingService]] = (
PromptBatchEncodingService
)
@classmethod
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
@@ -51,7 +57,9 @@ class EncodePromptBatch:
"default": "",
"multiline": True,
"tooltip": (
"Positive prompts in SEGS order, separated by [SEP]."
"Ordered positive prompt entries separated by [SEP] "
"or [SEP|name]; the global entry fills missing "
"positive regions."
),
},
),
@@ -61,15 +69,21 @@ class EncodePromptBatch:
"default": "",
"multiline": True,
"tooltip": (
"Negative prompts in SEGS order, separated by [SEP]."
"Ordered negative prompt entries separated by [SEP] "
"or [SEP|name]; the global entry fills missing "
"negative regions."
),
},
),
"separator": (
"STRING",
{
"default": "[SEP]",
"tooltip": "Text marker that separates prompt entries.",
"default": DEFAULT_PROMPT_BATCH_SEPARATOR,
"tooltip": (
"Text marker that separates prompt entries. With the "
"default [SEP], use [SEP|name] to add an organizational "
"label."
),
},
),
}
@@ -84,10 +98,9 @@ class EncodePromptBatch:
) -> tuple[object, object]:
"""Encode positive and negative prompt batches."""
encoder = self.encoder_class()
positive_chunks = split_prompt_batch(positive_prompt, separator)
negative_chunks = split_prompt_batch(negative_prompt, separator)
return (
encoder.encode_batch(clip, positive_chunks),
encoder.encode_batch(clip, negative_chunks),
return self.service_class(self.encoder_class()).encode(
clip=clip,
positive_prompt=positive_prompt,
negative_prompt=negative_prompt,
separator=separator,
)
+111
View File
@@ -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
"""ComfyUI node declaration for external LLM prompt generation."""
from __future__ import annotations
from typing import Any
from ..domain.external_llm import (
DEFAULT_EXTERNAL_LLM_MAX_TOKENS,
DEFAULT_EXTERNAL_LLM_REASONING_EFFORT,
EXTERNAL_LLM_REASONING_EFFORTS,
)
from ..services.external_llm_prompt_service import ExternalLLMPromptService
from . import tooltips
MAX_EXTERNAL_LLM_MAX_TOKENS = 32768
class ExternalLLMPrompt:
"""Expose an OpenAI-compatible external LLM prompt request as a string node."""
_service = ExternalLLMPromptService()
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("response",)
OUTPUT_TOOLTIPS = (tooltips.EXTERNAL_LLM_RESPONSE_OUTPUT,)
FUNCTION = "generate"
CATEGORY = "SimpleSyrup/Prompting"
DESCRIPTION = "Sends system and user prompts to a configured external LLM provider."
SEARCH_ALIASES = ["llm", "openai", "prompt", "preprocess", "text"]
@classmethod
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
"""Declare cached external LLM prompt inputs."""
choices = cls._service.model_choices()
return {
"required": {
"model": (
choices,
{
"default": choices[0],
"tooltip": tooltips.EXTERNAL_LLM_MODEL_INPUT,
},
),
"system_prompt": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": tooltips.EXTERNAL_LLM_SYSTEM_PROMPT_INPUT,
},
),
"user_prompt": (
"STRING",
{
"default": "",
"multiline": False,
"tooltip": tooltips.EXTERNAL_LLM_USER_PROMPT_INPUT,
},
),
"max_tokens": (
"INT",
{
"default": DEFAULT_EXTERNAL_LLM_MAX_TOKENS,
"min": 1,
"max": MAX_EXTERNAL_LLM_MAX_TOKENS,
"step": 1,
"tooltip": tooltips.EXTERNAL_LLM_MAX_TOKENS_INPUT,
},
),
"reasoning_effort": (
list(EXTERNAL_LLM_REASONING_EFFORTS),
{
"default": DEFAULT_EXTERNAL_LLM_REASONING_EFFORT,
"tooltip": tooltips.EXTERNAL_LLM_REASONING_EFFORT_INPUT,
},
),
},
"optional": {
"image": (
"IMAGE",
{"tooltip": tooltips.EXTERNAL_LLM_IMAGE_INPUT},
),
},
}
def generate(
self,
model: str,
system_prompt: str,
user_prompt: str,
max_tokens: int = DEFAULT_EXTERNAL_LLM_MAX_TOKENS,
reasoning_effort: str = DEFAULT_EXTERNAL_LLM_REASONING_EFFORT,
image: object | None = None,
) -> tuple[str]:
"""Return the external LLM assistant response."""
return (
self._service.generate(
model,
system_prompt,
user_prompt,
max_tokens,
reasoning_effort,
image=image,
),
)
+16 -5
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from typing import Any
from ..runtime.model_catalog import grounding_dino_choices, sam_choices
from ..runtime.model_choices import ModelChoiceService, default_choice
from ..runtime.model_metadata import GroundedSAMModelMetadata
from . import tooltips
@@ -17,6 +17,7 @@ class GroundedSAMModelInfo:
"""Expose selected grounded SAM source and local path metadata."""
_metadata = GroundedSAMModelMetadata()
_choices = ModelChoiceService()
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("model_info",)
@@ -31,19 +32,27 @@ class GroundedSAMModelInfo:
def INPUT_TYPES(cls) -> dict[str, dict[str, tuple[Any, ...]]]:
"""Declare deterministic model metadata inputs."""
sam_model_choices = cls._choices.sam_choices()
grounding_dino_model_choices = cls._choices.grounding_dino_choices()
return {
"required": {
"sam_model": (
sam_choices(),
sam_model_choices,
{
"default": "sam_hq_vit_b (379MB)",
"default": default_choice(
sam_model_choices,
"sam_hq_vit_b (379MB)",
),
"tooltip": tooltips.SAM_MODEL_INPUT,
},
),
"grounding_dino_model": (
grounding_dino_choices(),
grounding_dino_model_choices,
{
"default": "GroundingDINO_SwinT_OGC (694MB)",
"default": default_choice(
grounding_dino_model_choices,
"GroundingDINO_SwinT_OGC (694MB)",
),
"tooltip": tooltips.GROUNDING_DINO_MODEL_INPUT,
},
),
@@ -53,4 +62,6 @@ class GroundedSAMModelInfo:
def describe(self, sam_model: str, grounding_dino_model: str) -> tuple[str]:
"""Return JSON metadata for selected model entries."""
self._choices.reject_sentinel(sam_model)
self._choices.reject_sentinel(grounding_dino_model)
return (self._metadata.describe_selection(sam_model, grounding_dino_model),)
@@ -15,6 +15,7 @@ from ..runtime.grounding_dino_loader import (
)
from ..runtime.model_choices import ModelChoiceService, default_choice
from ..runtime.model_downloads import ComfyProgressReporter
from ..runtime.progress import create_comfy_phase_progress
from . import tooltips
@@ -72,5 +73,10 @@ class GroundingDINOModelLoader:
text_encoder=text_encoder,
auto_download=True,
progress=ComfyProgressReporter(),
phase_progress=create_comfy_phase_progress(
operation="grounding_dino_model_load",
subject=grounding_dino_model,
total_phases=6,
),
),
)

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